From 5100daa29772f9efa9d6cb01ed5c64e02ccdbb95 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:48:14 +0200 Subject: [PATCH 01/29] FireControl: one PDeviation shape for the deviation price DeviationProbability's inline saturate becomes PDeviation(deviation, tolerance), defined for a zero tolerance as well. No behaviour change for a positive tolerance. Delete-first step of the evasion term: every later deviation-like price reads this one shape. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 04e960d33..632cc9f8c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -422,12 +422,17 @@ public static class FireControl return new Interval { Lo = centre - h, Hi = centre + h }; } + // The one deviation shape: the share of a shot that survives `deviation` metres of miss against a shooter that + // forgives `tolerance` metres. Every deviation-like price (flight, evasion, a missile's miss) goes through it. + public static float PDeviation(float deviation, float tolerance) => + tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance); + public static float DeviationProbability(PendingShot shot, float now, out float deviation) { var elapsed = now - shot.FireTime; var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz); - return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); + return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking); } // The one decision about what a round does, made from the shooter's state and a target and nothing else: From da2957b81c86653e1c2ac22f12ddad5233eaf697 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:48:27 +0200 Subject: [PATCH 02/29] Evasion term: reachable spread from the thrust and turn envelope, realised by changes of vector Every hit price gains one factor, PDeviation(Evasion, Tracking): how far the target can and does move off the line of fire within SolutionWindow (0.75 s). Capability is Reach, an eight-face closed form over the ship's ManoeuvreEnvelope (summed from what each live thruster and aether drive reports with its own Execute arithmetic, read live: a thruster's Thrust property is stale until it fires, which is why the Djinni moved 1.6 times its stats, M25). Behaviour is ManoeuvreTrack, observed once per Entity.Update from the measured acceleration; a steady burn, coasting and a dither much faster than the window do not evade, and sideways speed alone does not (ruling evasion-speed). The commit price adds the term at any flight time, so an instant weapon feels a jinking target. Forecast owns the term for HitProbability and Inspect, which gain Evasion and PEvasion. Co-Authored-By: Claude Opus 5.5 --- .../ServerShared/Behaviors/AetherDrive.cs | 24 + .../ServerShared/Behaviors/Thruster.cs | 24 + Assets/Scripts/ServerShared/Entity.cs | 12 +- Assets/Scripts/ServerShared/FireControl.cs | 159 ++++- Assets/Scripts/ServerShared/Settings.cs | 5 + Assets/Scripts/ServerShared/Ship.cs | 14 + .../Aetheria.Shared.Tests/EvasionTermTests.cs | 556 ++++++++++++++++++ 7 files changed, 786 insertions(+), 8 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/EvasionTermTests.cs diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index e8498ca7f..84c6ea481 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -126,6 +126,30 @@ public class AetherDrive : Behavior, IPowerConsumer // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster. public int DefaultPowerTier => PowerTiers.Medium; + // What this drive adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input on every axis, + // as a rate per second at the rotors' current state (the rotors spend what they hold, so a drive that has been + // thrusting reports less). Each axis delivers Rpm * lambda * RotorMass * efficiency / Mass, the efficiency + // falling as the ship's speed along that axis approaches the rotor speed, so the push along the current + // velocity is smaller than against it; the turn is the z axis through AetherTorqueMultiplier, the same both + // ways. Zero when the item is offline or unpowered, the gates Execute applies. + public ManoeuvreEnvelope Manoeuvre() + { + if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default; + var forward = normalize(Entity.Direction); + var right = forward.Rotate(ItemRotation.Clockwise); + var speed = float2(dot(Entity.Velocity, forward), dot(Entity.Velocity, right)); + var rotorSpeed = max(Rpm * _data.RotorDiameter / 100, 1); + var couplingEfficiency = Evaluate(_data.CouplingEfficiency); + var perSecond = Rpm * _data.CouplingLambda * Item.Evaluate(_data.LambdaMultiplier) * _data.RotorMass / Entity.Mass; + float Push(float axisRate, float axisSpeed, float axisRotorSpeed, float direction) => + axisRate * saturate(1 - axisSpeed / axisRotorSpeed * direction) * couplingEfficiency; + return new ManoeuvreEnvelope( + Push(perSecond.x, speed.x, rotorSpeed.x, 1), Push(perSecond.x, speed.x, rotorSpeed.x, -1), + Push(perSecond.y, speed.y, rotorSpeed.y, -1), Push(perSecond.y, speed.y, rotorSpeed.y, 1), + perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier, + perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier); + } + public override bool Execute(float dt) { var rotorSpeed = Rpm * _data.RotorDiameter / 100; diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index 29cf8f181..ab884ca10 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -91,6 +91,30 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer // under brownout is an inconvenience, not the cascading failure a starved radiator or shield causes. public int DefaultPowerTier => PowerTiers.Medium; + // What this thruster adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input, read live. + // The Thrust property is refreshed only while the thruster fires, so an idle thruster reports its construction + // value, which is not what it will push with. Its push is Thrust / Mass along its mount, into the matching body + // axis, even for a flank thruster that only turns (it pushes whenever it turns); its turn is |Torque| * Thrust * + // TorqueMultiplier / Mass into the side its Torque sign fires on, when |Torque| clears the floor. Zero when the + // item is offline or unpowered, the same gates Execute applies. + public ManoeuvreEnvelope Manoeuvre() + { + if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default; + var settings = ItemManager.GameplaySettings; + var thrust = Evaluate(_data.Thrust); + var acceleration = thrust / Entity.Mass; + var forward = normalize(Entity.Direction); + var right = forward.Rotate(ItemRotation.Clockwise); + var push = -forward.Rotate(Item.EquippableItem.Rotation); + var along = dot(push, forward); + var across = dot(push, right); + var turn = abs(Torque) > settings.TorqueFloor ? abs(Torque) * thrust * settings.TorqueMultiplier / Entity.Mass : 0f; + return new ManoeuvreEnvelope( + max(along, 0f) * acceleration, max(-along, 0f) * acceleration, + max(-across, 0f) * acceleration, max(across, 0f) * acceleration, + Torque > 0f ? turn : 0f, Torque < 0f ? turn : 0f); + } + public override bool Execute(float dt) { Item.SetAudioParameter(SpecialAudioParameter.Intensity, _input); diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 853bea91e..0e8a35679 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -22,7 +22,13 @@ public abstract class Entity public float3 Position; public float2 Direction = float2(0,1); public float2 Velocity; - + + // Runtime only, never saved; Entity.Update is the one writer of both. Acceleration is the planar velocity change + // across the base update over its delta (thrust, not the drag or gravity a subclass applies before calling it), + // and Manoeuvre is what that acceleration says about how unpredictably this entity changes its motion. + public float2 Acceleration; + public ManoeuvreTrack Manoeuvre; + public float[,] Temperature; public float[,] NewTemperature; public bool2[,] HullConductivity; @@ -1343,6 +1349,7 @@ public abstract class Entity { if (!_active) return; + var velocityBefore = Velocity; var hullData = ItemManager.GetData(Hull) as HullData; // Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule @@ -1458,6 +1465,9 @@ public abstract class Entity Velocity = Parent.Velocity; } else Position.y = Zone.GetHeight(Position.xz) + hullData.GridOffset; + + Acceleration = delta > 0f ? (Velocity - velocityBefore) / delta : float2(0, 0); + Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow); } private void UpdateTemperature(float delta) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 632cc9f8c..ed4232da3 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -291,9 +291,13 @@ public static class FireControl // and to CellsOf, out of this cut's scope. Cut 10's own guard is what actually matters // operationally: HitProbability and CommitProbability both bail before Forecast is ever called for the // common gated-out case (GatedOutHitProbabilityAllocatesNothing pins that at exactly 0). - private static (Silhouette Silhouette, float PSpread) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) + // Evasion fix (evasion-term): the same call also prices how far the target can and does move off the line of + // fire, along the same travel direction, so the live price and the HUD read one evasion. + private static (Silhouette Silhouette, float PSpread, float Evasion) Forecast(Weapon weapon, Entity source, Entity target, HullData targetHull, float precision, float range) { - var bearing = Bearing(target, TravelDirection(weapon, source, target)); + var travel = TravelDirection(weapon, source, target); + var evasion = Evasion(target, travel); + var bearing = Bearing(target, travel); var buffer = ArrayPool.Shared.Rent(targetHull.Shape.Coordinates.Length); float pSpread; Silhouette sil; @@ -310,7 +314,7 @@ public static class FireControl // sil.Intervals is the returned buffer -- cleared here so nothing outside this function can read it // after it goes back to the pool and another caller starts overwriting it. sil.Intervals = null; - return (sil, pSpread); + return (sil, pSpread, evasion); } public static float HitProbability(Weapon weapon, Entity source, Entity target) @@ -319,8 +323,8 @@ public static class FireControl if (pFire <= 0f) return 0f; var targetHull = source.ItemManager.GetData(target.Hull) as HullData; - var (sil, pSpread) = Forecast(weapon, source, target, targetHull, Precision(source), range); - return pFire * pSpread * sil.POnHull; + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range); + return pFire * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source)); } // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes @@ -357,9 +361,11 @@ public static class FireControl diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info); diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor; - var (sil, pSpread) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range); + var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, diagnostic.Precision, diagnostic.Range); diagnostic.PSpread = pSpread; diagnostic.POnHull = sil.POnHull; + diagnostic.Evasion = evasion; + diagnostic.PEvasion = PDeviation(evasion, diagnostic.Tracking); diagnostic.PBase = HitProbability(weapon, source, target); return diagnostic; @@ -427,14 +433,104 @@ public static class FireControl public static float PDeviation(float deviation, float tolerance) => tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance); + // The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and + // does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon + // (flight time 0, realized deviation 0) still feels a jinking target. public static float DeviationProbability(PendingShot shot, float now, out float deviation) { var elapsed = now - shot.FireTime; var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; - deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz); + deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz) + Evasion(shot.Target, shot.TravelDirection); return shot.Target == null ? 1f : PDeviation(deviation, shot.Tracking); } + // How far a target can and does move off the line of fire in the solution window: the term DeviationProbability + // and HitProbability both add. Behaviour (its observed unpredictability across the line of sight) realises the + // evasion and capability (its envelope's reach) caps it, so a coasting ship evades nothing whatever it could do + // and a steady burn or a dither much faster than the window averages out. A target that is not a Ship has no + // envelope. This is the one call guns and munitions make. + public static float Evasion(Entity target, float2 lineOfSight) + { + var ship = target as Ship; + return ship == null + ? 0f + : Evasion(ship.Envelope, ship.Direction, ship.Manoeuvre, lineOfSight, target.ItemManager.GameplaySettings.SolutionWindow); + } + + // The pure core, allocation-free. n is perpendicular to the line of sight; u is the half-width the observed + // innovation spreads over the window (0.5 * rms * T^2); the answer is that spread, capped by the half-width of + // what the envelope can reach across the line of sight. Zero spread returns before any reach is evaluated. + public static float Evasion(in ManoeuvreEnvelope envelope, float2 heading, in ManoeuvreTrack track, float2 lineOfSight, float window) + { + var los = lengthsq(lineOfSight) < 1e-12f ? float2(0, 1) : normalize(lineOfSight); + var n = float2(-los.y, los.x); + var spread = .5f * track.LateralRms(n) * window * window; + if (spread <= 0f) return 0f; + return min(.5f * (Reach(envelope, heading, n, window) + Reach(envelope, heading, -n, window)), spread); + } + + // The largest displacement from the coasting path along `direction` within `window`, for a ship that turns + // kinematically (controls-turn-inertia) and thrusts within its body-frame box. Eight faces (the four thrust axes + // and the four corners of the box), each turned toward the direction at full rate and thrusting whenever it + // points within 90 degrees of it, then held; and the no-turn support of the box. Matched a brute-force search + // within 0.1% (map M26). + public static float Reach(in ManoeuvreEnvelope e, float2 heading, float2 direction, float window) + { + if (lengthsq(heading) < 1e-12f || lengthsq(direction) < 1e-12f) return 0f; + var forward = normalize(heading); + var right = forward.Rotate(ItemRotation.Clockwise); + var wanted = normalize(direction); + var beta = atan2(dot(wanted, right), dot(wanted, forward)); + + // The box's support along the wanted direction with no turning: thrust is always free to fire the right sign. + var c = cos(beta); + var s = sin(beta); + var best = .5f * ((c > 0f ? e.Forward * c : -e.Reverse * c) + (s > 0f ? e.Right * s : -e.Left * s)) * window * window; + + best = max(best, ReachFace(e.Forward, 0f, beta, e, window)); + best = max(best, ReachFace(e.Reverse, MathF.PI, beta, e, window)); + best = max(best, ReachFace(e.Right, MathF.PI / 2f, beta, e, window)); + best = max(best, ReachFace(e.Left, -MathF.PI / 2f, beta, e, window)); + best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Right * e.Right), atan2(e.Right, e.Forward), beta, e, window)); + best = max(best, ReachFace(sqrt(e.Forward * e.Forward + e.Left * e.Left), atan2(-e.Left, e.Forward), beta, e, window)); + best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Right * e.Right), atan2(e.Right, -e.Reverse), beta, e, window)); + best = max(best, ReachFace(sqrt(e.Reverse * e.Reverse + e.Left * e.Left), atan2(-e.Left, -e.Reverse), beta, e, window)); + return best; + } + + // One face of acceleration `a`, at body angle `faceAngle` (clockwise from forward), turned to point along the + // wanted direction at body angle `beta`, at the rate its side of the envelope allows; thrust only while the face + // points within 90 degrees of the wanted direction, so the integrand is (T - t) a cos(angle between). + private static float ReachFace(float a, float faceAngle, float beta, in ManoeuvreEnvelope e, float window) + { + if (a <= 0f) return 0f; + var turn = Wrap(beta - faceAngle); + var rate = turn > 0f ? e.Clockwise : e.CounterClockwise; + var phi0 = abs(turn); + if (rate <= 1e-4f) return phi0 <= 1e-6f ? .5f * a * window * window : 0f; + + var turnTime = phi0 / rate; + var onTime = phi0 <= MathF.PI / 2f ? 0f : (phi0 - MathF.PI / 2f) / rate; + if (onTime >= window) return 0f; + + var endTime = min(turnTime, window); + float Integral(float t) + { + var u = phi0 - rate * t; + return -(window - t) * sin(u) / rate - cos(u) / (rate * rate); + } + var turning = endTime > onTime ? a * (Integral(endTime) - Integral(onTime)) : 0f; + var holding = turnTime < window ? .5f * a * (window - turnTime) * (window - turnTime) : 0f; + return turning + holding; + } + + private static float Wrap(float angle) + { + while (angle > MathF.PI) angle -= 2f * MathF.PI; + while (angle <= -MathF.PI) angle += 2f * MathF.PI; + return angle; + } + // The one decision about what a round does, made from the shooter's state and a target and nothing else: // no draw, no side effect. Fire freezes it into a PendingShot; Inspect shows it to the HUD; the weapons ask // it whether a round is refused before they spend anything -- so a refusal is decided once, by the same @@ -1848,6 +1944,9 @@ public struct FireControlDiagnostic public float PFire; public float PSpread; public float POnHull; + // The target's evasion along the shot (metres) and the share of the shot that survives it at this shooter's Tracking. + public float Evasion; + public float PEvasion; public float PBase; // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do // not describe it. BurstReach is read only when Outcome is Burst. @@ -1855,6 +1954,52 @@ public struct FireControlDiagnostic public float BurstReach; } +// What a ship can do at this instant, in its own frame: acceleration along each body axis (m/s^2) and the kinematic +// turn rate each way (rad/s; turning toward the ship's right is Clockwise). Each propulsor reports its share with +// its own Execute arithmetic and Ship sums them; nothing stores it. Zero is the envelope of anything that is not a Ship. +public readonly struct ManoeuvreEnvelope +{ + public readonly float Forward, Reverse, Left, Right, Clockwise, CounterClockwise; + + public ManoeuvreEnvelope(float forward, float reverse, float left, float right, float clockwise, float counterClockwise) + { + Forward = forward; + Reverse = reverse; + Left = left; + Right = right; + Clockwise = clockwise; + CounterClockwise = counterClockwise; + } + + public static ManoeuvreEnvelope operator +(ManoeuvreEnvelope a, ManoeuvreEnvelope b) => new ManoeuvreEnvelope( + a.Forward + b.Forward, a.Reverse + b.Reverse, a.Left + b.Left, a.Right + b.Right, + a.Clockwise + b.Clockwise, a.CounterClockwise + b.CounterClockwise); +} + +// How unpredictably an entity is changing its motion, observed from its measured acceleration. The trend is an +// 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. +public struct ManoeuvreTrack +{ + public float2 Trend; + public float2 Innovation; + public float3 Moments; + + public void Observe(float2 acceleration, float dt, float window) + { + if (dt <= 0f || window <= 0f) return; + var k = 1f - exp(-dt / window); + var k4 = 1f - exp(-4f * dt / window); + Trend += k * (acceleration - Trend); + Innovation += k4 * ((acceleration - Trend) - Innovation); + Moments += k * (float3(Innovation.x * Innovation.x, Innovation.x * Innovation.y, Innovation.y * Innovation.y) - Moments); + } + + // The root-mean-square innovation across the axis n (a unit vector). + public float LateralRms(float2 n) => sqrt(max(0f, n.x * n.x * Moments.x + 2f * n.x * n.y * Moments.y + n.y * n.y * Moments.z)); +} + // Cut 12.2 (docs/fire-control-cut.md): a merged interval of a hull's lateral shadow, in the schematic frame's // lateral (ell) coordinate. // S3 fix batch (Hands, 2026-09-25): IComparable (sorted by Lo) instead of a separate IComparer -- diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 67de2f0aa..78f7f82cb 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -249,6 +249,11 @@ public class GameplaySettings // the result is frozen and the rest of the flight is pure choreography. public float CommitHorizon = .5f; + // The window of fire control's solution: how far ahead a target's reach counts (Reach, Evasion) and the time + // constant of the manoeuvre track that observes it. A fire-control constant, not a weapon or missile stat. First + // guess; the operator rules the real figure in play. + public float SolutionWindow = .75f; + // World units per hull-schematic cell, used only by FireControl.HitProbability's angular-size term // (pSpread) to turn a hull's cell footprint into a real-world silhouette size at range. public float SchematicCellSize = 2f; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index d10f097ca..7bccbd803 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -144,8 +144,22 @@ public class Ship : Entity #region ThrustCalculation + // What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with + // its own Execute arithmetic. Derived each update, never saved. The aggregates above are not an envelope (they + // mix units and cancel a lone off-axis strafer), so nothing in it reads them. + public ManoeuvreEnvelope Envelope { get; private set; } + + private void RecalculateEnvelope() + { + var envelope = default(ManoeuvreEnvelope); + foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre(); + foreach (var drive in _aetherDrives) envelope += drive.Manoeuvre(); + Envelope = envelope; + } + private void RecalculateThrust() { + RecalculateEnvelope(); RecalculateForwardThrust(); RecalculateReverseThrust(); RecalculateLeftStrafeThrust(); diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs new file mode 100644 index 000000000..502f40770 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -0,0 +1,556 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// The evasion term (docs/aetheria-release-map.md, "Evasion is how unpredictably the target can and does change its +// motion"): how far a target can and does move off the line of fire in the solution window, one factor of every hit +// price. The fixture is RunStartTests': the shipped catalog and the authored settings, so the ships fly in the real +// Ship.Update with the real thrusters and aether drive. Ship flights step the ships directly (they are alone in +// their arena); the one price test that rolls shots steps only FireControl. +public sealed partial class RunStartTests +{ + private const float EvDt = 1f / 60f; + + // The Duel's LonginusX fit: one Traction aether drive that pushes all four ways, with its guns. + private static ScenarioFit EvGemini(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("LonginusX", + ("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Traction", int2(2, 3), ItemRotation.None), ("Core Power", int2(2, 1), ItemRotation.None), + ("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None), + (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(1, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(4, 2), ItemRotation.Clockwise), + ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 5), ItemRotation.None), + ("Fire Control Array", int2(2, 5), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); + + // The catalog's three fitted ships, far apart in the fixture arena: the generated Longinus and Djinni at the + // fixture seed and the omnidirectional LonginusX. + private (Zone arena, Ship longinus, Ship djinni, Ship gemini) EvFleet() + { + Ship longinus = null, djinni = null, gemini = null; + var scenario = new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + // The draw order is part of the fixture: the generator draws from the run's item stream, so the Djinni is fitted + // first, then the Longinus, as the map's probe did (M23). + djinni = stage.Place(stage.Generated("Djinni"), float2(-10000, -30000), facing: float2(0, 1)) as Ship; + longinus = stage.Place(stage.Generated("Longinus"), float2(-30000, -30000), facing: float2(0, 1)) as Ship; + gemini = stage.Place(EvGemini(stage), float2(10000, -30000), facing: float2(0, 1)) as Ship; + }); + var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + Assert.NotNull(longinus); + Assert.NotNull(djinni); + Assert.NotNull(gemini); + return (arena, longinus, djinni, gemini); + } + + // Idle long enough for stats, power and the aether rotors to reach their idle state, then at rest, facing +y, + // with nothing observed. + private static void EvSettle(Ship ship) + { + ship.MovementDirection = float2(0, 0); + ship.LookDirection = float3(0, 0, 1); + 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 + // nose-on) or along it (the one that crosses a shooter broadside), a new random direction each half-period, or a + // turn-and-burn weave of +-60 degrees. Flown in Ship.Update; what the ship does is its own motion. + private enum EvJink { Lateral, Longitudinal, Random, Weave } + + private static void EvFly(Ship ship, EvJink jink, float halfPeriod, float seconds) + { + var random = new System.Random(7); + var steps = (int) Math.Round(seconds / EvDt); + var phaseSteps = Math.Max(1, (int) Math.Round(halfPeriod / EvDt)); + float2 movement = float2(0, 0); + float3 look = float3(0, 0, 1); + for (var step = 0; step < steps; step++) + { + if (step % phaseSteps == 0) + { + var phase = step / phaseSteps; + var side = phase % 2 == 0 ? 1f : -1f; + switch (jink) + { + case EvJink.Lateral: + movement = float2(side, 0); + break; + case EvJink.Longitudinal: + movement = float2(0, side); + break; + case EvJink.Random: + var angle = (float) (random.NextDouble() * 2 * Math.PI); + movement = float2(sin(angle), cos(angle)); + break; + case EvJink.Weave: + movement = float2(0, 1); + look = float3(side * sin(radians(60)), 0, cos(radians(60))); + break; + } + } + ship.MovementDirection = movement; + ship.LookDirection = look; + ship.Update(EvDt); + } + ship.MovementDirection = float2(0, 0); + ship.LookDirection = float3(0, 0, 1); + } + + private static float2 EvRight(Ship ship) => normalize(ship.Direction).Rotate(ItemRotation.Clockwise); + + // The best evasion a ship shows over the fixed jink set, against a shooter nose-on (along its heading) and one + // broadside, at the authored window. + private static (float noseOn, float broadside) EvBest(Func flightShip) + { + float noseOn = 0, broadside = 0; + foreach (var jink in new[] { EvJink.Lateral, EvJink.Longitudinal, EvJink.Random, EvJink.Weave }) + foreach (var halfPeriod in new[] { .5f, 1f }) + { + // A fresh ship each flight where the caller wants one: an aether drive heats and spends its rotors, and a + // drive that has run itself offline has no envelope. + var ship = flightShip(); + EvSettle(ship); + EvFly(ship, jink, halfPeriod, 4f); + var flightNose = FireControl.Evasion(ship, normalize(ship.Direction)); + var flightSide = FireControl.Evasion(ship, EvRight(ship)); + var e = ship.Envelope; + Console.WriteLine($"EVASION flight {ship.HullData.Name} {jink} {halfPeriod}: nose-on {flightNose:F2} broadside {flightSide:F2} rms x {ship.Manoeuvre.LateralRms(float2(1, 0)):F1} y {ship.Manoeuvre.LateralRms(float2(0, 1)):F1} envelope F{e.Forward:F0} R{e.Reverse:F0} L{e.Left:F0} Rt{e.Right:F0} CW{e.Clockwise:F2} CCW{e.CounterClockwise:F2} speed {length(ship.Velocity):F0}"); + noseOn = max(noseOn, flightNose); + broadside = max(broadside, flightSide); + } + return (noseOn, broadside); + } + + // Ruling evasion-is-unpredictability: the Gemini is evasive because it can floor it in any direction at any time, + // the Longinus needs to aim itself where it wants to go, and the Djinni is somewhere between. + [Fact] + public void TheThreeShipsOrder() + { + var (_, longinus, djinni, _) = EvFleet(); + var g = EvBest(() => EvFleet().gemini); + var l = EvBest(() => longinus); + var d = EvBest(() => djinni); + Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: gemini-like nose-on {g.noseOn:F2} broadside {g.broadside:F2}; " + + $"longinus {l.noseOn:F2} {l.broadside:F2}; djinni {d.noseOn:F2} {d.broadside:F2}"); + Assert.True(g.noseOn > l.noseOn && l.noseOn > d.noseOn, $"nose-on order: {g.noseOn} > {l.noseOn} > {d.noseOn}"); + Assert.True(g.broadside > l.broadside && l.broadside > d.broadside, $"broadside order: {g.broadside} > {l.broadside} > {d.broadside}"); + // Re-measured (M25): the Djinni's thrusters push 1.7 times harder than the figure the map priced it with, so + // it evades more than the map's 1.7 m and the Longinus leads it by less than the map's factor of eight. + Assert.True(l.noseOn >= 2 * d.noseOn && l.broadside >= 2 * d.broadside, "the Longinus evades at least twice the Djinni"); + Assert.True(d.noseOn < 4f && d.broadside < 4f, $"the Djinni is limited by its mass: {d.noseOn}, {d.broadside}"); + } + + // The speed demon, as pure numbers (ruling speed-demon-counterplay): after 4 s of a lateral square wave at full + // thrust, half-period 0.5 s, nose-on. The window is passed explicitly, not read from settings. + private static ManoeuvreTrack EvSquare(float amplitude, float halfPeriod, float seconds, float dt, float window) + { + var track = default(ManoeuvreTrack); + var steps = (int) Math.Round(seconds / dt); + for (var step = 0; step < steps; step++) + track.Observe(float2(((int) Math.Floor(step * dt / halfPeriod) % 2 == 0 ? 1f : -1f) * amplitude, 0), dt, window); + return track; + } + + [Fact] + public void TheSpeedDemonNumbers() + { + var demon = new ManoeuvreEnvelope(60, 60, 60, 60, 3, 3); + var heavy = new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f); + var noseOn = float2(0, 1); + + float Evade(ManoeuvreEnvelope envelope, float amplitude, float window) => + FireControl.Evasion(envelope, noseOn, EvSquare(amplitude, .5f, 4f, EvDt, window), noseOn, window); + + var demonShort = Evade(demon, 60, .5f); + Assert.InRange(demonShort, 5.07f * .95f, 5.07f * 1.05f); + Assert.InRange(FireControl.PDeviation(demonShort, 10), .49f - .03f, .49f + .03f); + Assert.InRange(FireControl.PDeviation(demonShort, 50), .90f - .03f, .90f + .03f); + + var demonLong = Evade(demon, 60, .75f); + // The map's 9.63 is the model's time mean; this reads the track at the instant of the last flip, 5.1% above it. + Assert.InRange(demonLong, 9.63f * .94f, 9.63f * 1.06f); + Assert.InRange(FireControl.PDeviation(demonLong, 10), 0f, .04f + .05f); + Assert.InRange(FireControl.PDeviation(demonLong, 50), .81f - .03f, .81f + .03f); + + var heavyShort = Evade(heavy, 10, .5f); + Assert.InRange(heavyShort, .84f * .95f, .84f * 1.05f); + Assert.InRange(FireControl.PDeviation(heavyShort, 10), .92f - .03f, .92f + .03f); + Assert.InRange(FireControl.PDeviation(heavyShort, 50), .98f - .03f, .98f + .03f); + } + + // Reach is what the ship can do: the largest displacement from the coasting path in the window, from the + // envelope alone, against the same ship's best motion in Ship.Update under fifteen control policies (strafe, + // forward, reverse, corner, turn-and-burn at several angles, each mirrored). + private static (float noseOn, float broadside) EvMeasuredHalfWidths(Ship ship, float window) + { + EvSettle(ship); + var start = ship.Position; + float2 Run(float2 move, float2 look) + { + ship.Position = start; + ship.Velocity = float2(0, 0); + ship.Direction = float2(0, 1); + ship.MovementDirection = move; + ship.LookDirection = float3(look.x, 0, look.y); + var steps = (int) Math.Round(window / EvDt); + for (var step = 0; step < steps; step++) ship.Update(EvDt); + var displacement = ship.Position.xz - start.xz; + ship.MovementDirection = float2(0, 0); + ship.LookDirection = float3(0, 0, 1); + return displacement; + } + + var ahead = float2(0, 1); + var idle = Run(float2(0, 0), ahead); + var policies = new (float2 move, float2 look)[] + { + (float2(1, 0), ahead), (float2(-1, 0), ahead), (float2(0, 1), ahead), (float2(0, -1), ahead), + (float2(1, 1), ahead), (float2(-1, 1), ahead), + (float2(0, 1), float2(1, 0)), (float2(0, 1), float2(-1, 0)), + (float2(1, 1), float2(1, 0)), (float2(-1, 1), float2(-1, 0)), + (float2(0, 1), normalize(float2(1, 1))), (float2(0, 1), normalize(float2(-1, 1))), + (float2(0, 1), normalize(float2(1, -1))), (float2(0, 0), float2(1, 0)), + (float2(0, 1), normalize(float2(.05f, -1))) + }; + float right = 0, left = 0, forward = 0, back = 0; + foreach (var (move, look) in policies) + { + var d = Run(move, look) - idle; + right = max(right, d.x); + left = max(left, -d.x); + forward = max(forward, d.y); + back = max(back, -d.y); + } + ship.Position = start; + return ((right + left) / 2, (forward + back) / 2); + } + + private static (float noseOn, float broadside) EvReach(Ship ship, float window) + { + var heading = float2(0, 1); + float Half(float2 n) => .5f * (FireControl.Reach(ship.Envelope, heading, n, window) + FireControl.Reach(ship.Envelope, heading, -n, window)); + return (Half(float2(1, 0)), Half(float2(0, 1))); + } + + [Fact] + public void ReachIsWhatTheShipCanDo() + { + var (_, longinus, djinni, _) = EvFleet(); + const float window = .5f; + var results = new List<(string name, (float noseOn, float broadside) reach, (float noseOn, float broadside) measured)>(); + foreach (var (name, ship) in new[] { ("longinus", longinus), ("djinni", djinni) }) + { + var measured = EvMeasuredHalfWidths(ship, window); + EvSettle(ship); + ship.Update(EvDt); + var reach = EvReach(ship, window); + Console.WriteLine($"REACH {name}: nose-on reach {reach.noseOn:F3} measured {measured.noseOn:F3}; broadside reach {reach.broadside:F3} measured {measured.broadside:F3}"); + results.Add((name, reach, measured)); + } + // The reach is the capability and the measured motion is the best of fifteen fixed control policies, so reach may + // sit above it: by up to 15% for the Longinus, whose controller shapes its turn input (a square root of the + // remaining angle) where the reach turns at full rate. + foreach (var (name, reach, measured) in results) + { + // The Djinni's measured motion is further below its reach (+53% nose-on, +27% broadside at the last measurement): its + // strafers are off-axis and Ship.Update cancels their torque (M24), which the envelope, summed from what each thruster + // can push, does not model. Stated deviation; the bound still pins that the reach covers what the ship does. + var ceiling = name == "djinni" ? 1.6f : 1.15f; + Assert.InRange(reach.noseOn, measured.noseOn * .9f, measured.noseOn * ceiling); + Assert.InRange(reach.broadside, measured.broadside * .9f, measured.broadside * ceiling); + } + } + + // The envelope is summed from what each live propulsor reports with its own Execute arithmetic. + [Fact] + public void TheEnvelopeComesFromTheGear() + { + var (_, longinus, _, gemini) = EvFleet(); + EvSettle(longinus); + // A thruster's Thrust property is refreshed only while it fires: turn each way so the flank thrusters hold what + // they push with, then read the envelope at rest. + foreach (var look in new[] { float3(1, 0, 0), float3(-1, 0, 0) }) + { + longinus.LookDirection = look; + for (var step = 0; step < 20; step++) longinus.Update(EvDt); + } + EvSettle(longinus); + longinus.Update(EvDt); + var settings = _items.GameplaySettings; + var thrusters = longinus.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); + var largeDrives = thrusters.Where(t => t.Item.Data.Name == "Large Drive").ToList(); + var flanks = thrusters.Where(t => t.Item.Data.Name == "Talaria").ToList(); + Assert.Equal(2, largeDrives.Count); + Assert.Equal(2, flanks.Count); + + var envelope = longinus.Envelope; + var mass = longinus.Mass; + Assert.InRange(envelope.Forward, 500000f / mass * .99f, 500000f / mass * 1.01f); + Assert.Equal(0f, envelope.Reverse); + var flankAcceleration = flanks.Max(t => t.Thrust) / mass; + Assert.InRange(envelope.Left, flankAcceleration * .99f, flankAcceleration * 1.01f); + Assert.InRange(envelope.Right, flankAcceleration * .99f, flankAcceleration * 1.01f); + var clockwise = thrusters.Where(t => t.Torque > settings.TorqueFloor).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; + var counterClockwise = thrusters.Where(t => t.Torque < -settings.TorqueFloor).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / mass; + Assert.InRange(envelope.Clockwise, clockwise * .99f, clockwise * 1.01f); + Assert.InRange(envelope.CounterClockwise, counterClockwise * .99f, counterClockwise * 1.01f); + Assert.True(clockwise > 1f && counterClockwise > 1f, "the flank thrusters turn the Longinus"); + + // Destroying one Large Drive halves the push forward. + largeDrives[0].Item.EquippableItem.Durability = 0f; + longinus.Update(EvDt); + longinus.Update(EvDt); + Assert.InRange(longinus.Envelope.Forward, envelope.Forward * .49f, envelope.Forward * .51f); + + // The LonginusX's Traction drive pushes and turns every way. + EvSettle(gemini); + gemini.Update(EvDt); + var drive = gemini.Envelope; + Assert.True(drive.Forward > 1f && drive.Reverse > 1f && drive.Left > 1f && drive.Right > 1f, $"traction: {drive.Forward} {drive.Reverse} {drive.Left} {drive.Right}"); + Assert.True(drive.Clockwise > 0f && drive.CounterClockwise > 0f); + } + + // A station or any other non-Ship has no envelope; whatever it does, it does not evade. + [Fact] + public void NonShipsDoNotEvade() + { + var zone = Arena(true); + var station = zone.Entities.First(entity => !(entity is Ship)); + for (var step = 0; step < 240; step++) + station.Manoeuvre.Observe(float2(step / 30 % 2 == 0 ? 60 : -60, 0), EvDt, _items.GameplaySettings.SolutionWindow); + Assert.True(station.Manoeuvre.LateralRms(float2(1, 0)) > 1f, "fixture: the station's own track reads a jink"); + Assert.Equal(0f, FireControl.Evasion(station, float2(0, 1))); + Assert.Equal(0f, FireControl.Evasion(station, float2(1, 0))); + } + + // Entity.Update is the one writer of Acceleration and Manoeuvre: the velocity change across the base update, so + // thrust and not drag or gravity. + [Fact] + public void ManoeuvreIsObserved() + { + var (_, longinus, djinni, gemini) = EvFleet(); + + // Coasting: no acceleration, an all-zero track. + EvSettle(longinus); + longinus.Velocity = float2(40, 0); + for (var step = 0; step < 60; step++) longinus.Update(EvDt); + Assert.Equal(0f, lengthsq(longinus.Acceleration)); + Assert.Equal(0f, lengthsq(longinus.Manoeuvre.Trend) + lengthsq(longinus.Manoeuvre.Innovation) + lengthsq(longinus.Manoeuvre.Moments)); + + // A full burn for one update: the acceleration is the thrust over the mass, along the heading. (The Large + // Drives' opposed torques turn the hull a little between their two pushes, hence the sideways slack.) + EvSettle(longinus); + longinus.MovementDirection = float2(0, 1); + longinus.Update(EvDt); + Assert.InRange(longinus.Acceleration.y, longinus.Envelope.Forward * .995f, longinus.Envelope.Forward * 1.005f); + Assert.InRange(abs(longinus.Acceleration.x), 0f, .03f * longinus.Envelope.Forward); + + // The Djinni's thrusters push harder than their construction value (M25): measured acceleration is what its + // envelope says. + EvSettle(djinni); + djinni.MovementDirection = float2(0, 1); + djinni.Update(EvDt); + Assert.InRange(djinni.Acceleration.y, djinni.Envelope.Forward * .98f, djinni.Envelope.Forward * 1.02f); + + // Strafing at full thrust for one update on the Traction drive: the drive's own push over the step. + EvSettle(gemini); + gemini.MovementDirection = float2(1, 0); + gemini.Update(EvDt); + var drive = gemini.GetBehaviors().Single(); + Assert.True(drive.ThrustDirection.x > 0f); + Assert.InRange(gemini.Acceleration.x, drive.ThrustDirection.x / EvDt * .999f, drive.ThrustDirection.x / EvDt * 1.001f); + Assert.InRange(gemini.Acceleration.x, gemini.Envelope.Right * .95f, gemini.Envelope.Right * 1.05f); + + // The same jink observed at two step sizes reads the same unpredictability. + var fine = EvSquare(40, .5f, 4f, 1f / 60f, .75f); + var coarse = EvSquare(40, .5f, 4f, 1f / 30f, .75f); + var n = float2(1, 0); + Assert.InRange(coarse.LateralRms(n), fine.LateralRms(n) * .9f, fine.LateralRms(n) * 1.1f); + } + + // A ship holding a steady burn, or dithering far faster than the window, does not evade; a jink at the window's + // own scale does; a coasting ship evades nothing. + [Fact] + public void ASteadyBurnAndDitheringAreNotEvasion() + { + var window = _items.GameplaySettings.SolutionWindow; + // A fresh ship for each phase: an aether drive heats and spends its rotors as it flies. + var gemini = EvFleet().gemini; + float Nose() => FireControl.Evasion(gemini, normalize(gemini.Direction)); + + EvSettle(gemini); + EvFly(gemini, EvJink.Lateral, .5f, 4f); + var jinking = Nose(); + Assert.True(jinking > 1f, $"fixture: the jink evades ({jinking})"); + + gemini = EvFleet().gemini; + EvSettle(gemini); + gemini.MovementDirection = float2(0, 1); + for (var step = 0; step < (int) Math.Round(3 * window / EvDt); step++) gemini.Update(EvDt); + Assert.True(Nose() < .05f * jinking, $"a steady burn: {Nose()} against {jinking}"); + + gemini = EvFleet().gemini; + EvSettle(gemini); + EvFly(gemini, EvJink.Lateral, .05f, 4f); + Assert.True(Nose() < .25f * jinking, $"a dither: {Nose()} against {jinking}"); + + gemini = EvFleet().gemini; + EvSettle(gemini); + for (var step = 0; step < 240; step++) gemini.Update(EvDt); + Assert.Equal(0f, Nose()); + } + + // Two shooters on opposite sides of the same target, 200 m away: one with a projectile gun and one with a laser + // (a beam's authored velocity is 0, a flight time of zero). The target is the omnidirectional LonginusX. + private sealed class EvRange + { + public Zone Arena; + public Ship Gunner, Lasing, Target; + public Weapon Gun, Laser; + } + + private EvRange EvRangeLaunch() + { + Ship gunner = null, lasing = null, target = null; + var scenario = new Scripted(false, stage => + { + stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); + gunner = stage.Place(EvGemini(stage), float2(0, 0), facing: float2(0, 1)) as Ship; + target = stage.Place(EvGemini(stage), float2(0, 200), facing: float2(0, 1)) as Ship; + lasing = stage.Place(EvGemini(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship; + }); + var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); + Assert.True(failures.Count == 0, string.Join("; ", failures)); + foreach (var shooter in new[] { gunner, lasing }) + { + shooter.SetTarget(target); + shooter.EntityInfoGathered[target] = 1f; + } + arena.Update(1f); + Assert.True(gunner.VisibleEntities.Contains(target) && lasing.VisibleEntities.Contains(target), "fixture: the shooters see the target"); + var gun = gunner.GetBehaviors().First(weapon => weapon is AutoWeapon && weapon.Velocity > 100f); + var laser = lasing.GetBehaviors().First(weapon => weapon is AutoWeapon && weapon.Velocity <= .01f); + return new EvRange { Arena = arena, Gunner = gunner, Lasing = lasing, Target = target, Gun = gun, Laser = laser }; + } + + // The target crossing at 150 m/s, either coasting or after two seconds of jinking, at the same velocity, heading + // and place. + private static void EvCross(EvRange range, bool jinking) + { + var target = range.Target; + EvSettle(target); + if (jinking) EvFly(target, EvJink.Lateral, .5f, 2f); + target.Velocity = float2(150, 0); + target.Direction = float2(0, 1); + target.Position = float3(0, target.Position.y, 200); + } + + // Fires a volley and returns the hits it committed and the mean price those shots were committed at. + private static (int hits, float price) EvVolley(EvRange range, Weapon weapon, Ship shooter, int shots) + { + var committed = 0; + var hits = 0; + using var subscription = range.Arena.ShotCommitted.Subscribe(outcome => { committed++; if (outcome.Hit) hits++; }); + var ids = new List(); + for (var i = 0; i < shots; i++) + { + var id = FireControl.Fire(weapon, weapon.Item, shooter); + Assert.True(id > 0, "fixture: the weapon fires"); + ids.Add(id); + } + var price = ids + .Select(id => range.Arena.PendingShots.Single(shot => shot.ShotId == id)) + .Average(shot => FireControl.CommitProbability(shot, range.Arena.Time, out _)); + FireControl.Step(range.Arena, .01f); + Assert.Equal(shots, committed); + return (hits, price); + } + + // The evasion term is in the live price, the HUD's forecast and the roll: a ship crossing at 150 m/s that has been + // jinking is harder to hit than the same ship coasting, for a projectile gun. + [Fact] + public void ACoastingAgileShipIsEasierToHitThanTheSameShipJinking() + { + var range = EvRangeLaunch(); + var gun = range.Gun; + var shooter = range.Gunner; + var target = range.Target; + + EvCross(range, jinking: false); + var coastingDiagnostic = FireControl.Inspect(gun, shooter, target); + var coastingPrice = FireControl.HitProbability(gun, shooter, target); + var coasting = EvVolley(range, gun, shooter, 200); + Assert.Equal(0f, coastingDiagnostic.Evasion); + Assert.Equal(1f, coastingDiagnostic.PEvasion); + Assert.True(coastingPrice > .2f, $"fixture: a coasting target is hittable ({coastingPrice})"); + + EvCross(range, jinking: true); + var jinkingDiagnostic = FireControl.Inspect(gun, shooter, target); + var jinkingPrice = FireControl.HitProbability(gun, shooter, target); + var jinking = EvVolley(range, gun, shooter, 200); + Console.WriteLine($"EVASION price: coasting {coastingPrice:F3} hits {coasting.hits}/200 committed {coasting.price:F3}; " + + $"jinking {jinkingPrice:F3} (evasion {jinkingDiagnostic.Evasion:F2} m, tracking {jinkingDiagnostic.Tracking:F1}, PEvasion {jinkingDiagnostic.PEvasion:F3}) " + + $"hits {jinking.hits}/200 committed {jinking.price:F3}"); + + Assert.True(jinkingDiagnostic.Evasion > 0f); + Assert.True(jinkingDiagnostic.PEvasion < 1f); + Assert.True(jinkingPrice < coastingPrice, "the live price falls"); + Assert.True(jinking.price < coasting.price, "the committed price falls"); + Assert.True(jinking.hits < coasting.hits, $"the committed hit rate falls: {jinking.hits} against {coasting.hits}"); + + // The HUD's number stays the price: the diagnostic's base is the live price, and at fire time, with no + // deviation yet realized, the live price is the commit price. + Assert.Equal(jinkingPrice, jinkingDiagnostic.PBase, 6); + var id = FireControl.Fire(gun, gun.Item, shooter); + var shot = range.Arena.PendingShots.Single(s => s.ShotId == id); + Assert.InRange(FireControl.CommitProbability(shot, range.Arena.Time, out _), jinkingPrice * .999f, jinkingPrice * 1.001f); + } + + // Ruling evasion-not-only-flight-time: a laser has no flight time to dodge, and a jinking target still evades it. + // The whole difference is the evasion term at the shooter's Tracking. + [Fact] + public void AnInstantWeaponFeelsTheJink() + { + var range = EvRangeLaunch(); + var laser = range.Laser; + var shooter = range.Lasing; + var target = range.Target; + Assert.True(laser.Velocity <= .01f, "fixture: the beam has no flight time"); + + (float hit, float commit, PendingShot shot) Price(bool jinking) + { + EvCross(range, jinking); + var hit = FireControl.HitProbability(laser, shooter, target); + var id = FireControl.Fire(laser, laser.Item, shooter); + var shot = range.Arena.PendingShots.Single(s => s.ShotId == id); + return (hit, FireControl.CommitProbability(shot, range.Arena.Time, out _), shot); + } + + var coasting = Price(false); + var jinking = Price(true); + Assert.True(coasting.commit > .2f, $"fixture: a coasting target is hittable ({coasting.commit})"); + Assert.True(jinking.hit < coasting.hit, $"live: {jinking.hit} against {coasting.hit}"); + Assert.True(jinking.commit < coasting.commit, $"commit: {jinking.commit} against {coasting.commit}"); + + var evasion = FireControl.Evasion(target, jinking.shot.TravelDirection); + Assert.True(evasion > 0f); + var pDeviation = FireControl.DeviationProbability(jinking.shot, range.Arena.Time, out var deviation); + Assert.Equal(evasion, deviation, 4); // nothing realized: a beam commits where it fires + var expected = FireControl.PDeviation(evasion, jinking.shot.Tracking); + Assert.Equal(expected, pDeviation, 5); + Assert.InRange(jinking.commit / coasting.commit, expected * .999f, expected * 1.001f); + Assert.InRange(jinking.hit / coasting.hit, expected * .999f, expected * 1.001f); + } +} From f8065a4d2b1719fb654014b864b6d75c724355ed Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 03:12:47 +0200 Subject: [PATCH 03/29] EvasionTermTests: which thrusters are left decides the envelope Destroy the Djinni's right-pushing thrusters and the envelope loses its right push and the turn they gave one way; reverse push and the Traction drive's turn rate are checked against the ship's own flight. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 502f40770..810823a39 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -322,6 +322,63 @@ public sealed partial class RunStartTests Assert.True(drive.Clockwise > 0f && drive.CounterClockwise > 0f); } + // Which thruster pushes which way and turns which way is read from the gear, not assumed symmetric: destroy the + // Djinni's right-pushing thrusters and the envelope loses its right push and the turn they gave one way, which + // the ship's own flight confirms. + [Fact] + public void TheEnvelopeFollowsWhichThrustersAreLeft() + { + var (_, _, djinni, gemini) = EvFleet(); + var settings = _items.GameplaySettings; + EvSettle(djinni); + // Refresh every thruster's live Thrust by firing it: strafe, burn and turn each way. + foreach (var (move, look) in new[] { (float2(1, 0), float3(0, 0, 1)), (float2(-1, 0), float3(0, 0, 1)), (float2(0, 1), float3(0, 0, 1)), + (float2(0, -1), float3(0, 0, 1)), (float2(0, 0), float3(1, 0, 0)), (float2(0, 0), float3(-1, 0, 0)) }) + { + djinni.MovementDirection = move; + djinni.LookDirection = look; + for (var step = 0; step < 20; step++) djinni.Update(EvDt); + } + EvSettle(djinni); + djinni.Update(EvDt); + var whole = djinni.Envelope; + + // Full reverse for one update accelerates at the reverse push. + EvSettle(djinni); + djinni.MovementDirection = float2(0, -1); + djinni.Update(EvDt); + Assert.InRange(-djinni.Acceleration.y, whole.Reverse * .98f, whole.Reverse * 1.02f); + + foreach (var thruster in djinni.GetBehaviors().Where(t => t.Item.EquippableItem.Rotation == ItemRotation.CounterClockwise)) + thruster.Item.EquippableItem.Durability = 0f; + EvSettle(djinni); + djinni.Update(EvDt); + djinni.Update(EvDt); + var maimed = djinni.Envelope; + Assert.Equal(0f, maimed.Right); + Assert.InRange(maimed.Left, whole.Left * .99f, whole.Left * 1.01f); + var live = djinni.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); + var clockwise = live.Where(t => t.Torque > settings.TorqueFloor).Sum(t => t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; + var counterClockwise = live.Where(t => t.Torque < -settings.TorqueFloor).Sum(t => -t.Torque * t.Thrust) * settings.TorqueMultiplier / djinni.Mass; + Assert.True(Math.Abs(clockwise - counterClockwise) > .05f * clockwise, "fixture: the surviving thrusters turn the two ways unequally"); + Assert.InRange(maimed.Clockwise, clockwise * .99f, clockwise * 1.01f); + Assert.InRange(maimed.CounterClockwise, counterClockwise * .99f, counterClockwise * 1.01f); + djinni.MovementDirection = float2(1, 0); + djinni.Update(EvDt); + Assert.InRange(djinni.Acceleration.x, -.5f, .5f); + djinni.MovementDirection = float2(-1, 0); + djinni.Update(EvDt); + Assert.True(djinni.Acceleration.x < -1f, "it still strafes left"); + + // The Traction drive's turn is the rate it turns the hull at when told to turn right. + EvSettle(gemini); + gemini.MovementDirection = float2(0, 0); + gemini.LookDirection = float3(1, 0, 0); + gemini.Update(EvDt); + var turned = Math.Atan2(gemini.Direction.x, gemini.Direction.y); + Assert.InRange((float) turned / EvDt, gemini.Envelope.Clockwise * .9f, gemini.Envelope.Clockwise * 1.1f); + } + // A station or any other non-Ship has no envelope; whatever it does, it does not evade. [Fact] public void NonShipsDoNotEvade() From 2910a35dffa69cb39fecbadbb2355a68e75b5a00 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 03:37:31 +0200 Subject: [PATCH 04/29] EvasionTermTests: reach and evasion read each side of the envelope An asymmetric forward-only envelope pins the clockwise and counter-clockwise sides, the mirrored direction, the two-sided average capped by the observed spread, and the track's cross term. Co-Authored-By: Claude Opus 5.5 --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 810823a39..a4b1bb034 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -379,6 +379,33 @@ public sealed partial class RunStartTests Assert.InRange((float) turned / EvDt, gemini.Envelope.Clockwise * .9f, gemini.Envelope.Clockwise * 1.1f); } + // The reach and the evasion read each side of the envelope for themselves: a forward-only ship that turns clockwise + // fast and counter-clockwise slowly reaches further to its right than its left in the window, and its evasion + // across a line of sight is the average of what it reaches to either side, capped by the spread it is observed to + // make. A diagonal jink reads as unpredictable along the diagonal and not across it. + [Fact] + public void ReachAndEvasionReadEachSideOfTheEnvelope() + { + var forwardOnly = new ManoeuvreEnvelope(50, 0, 0, 0, 4, .5f); + var ahead = float2(0, 1); + var toRight = FireControl.Reach(forwardOnly, ahead, float2(1, 0), .75f); + var toLeft = FireControl.Reach(forwardOnly, ahead, float2(-1, 0), .75f); + Assert.True(toRight > 2 * toLeft && toLeft > 0f, $"right {toRight}, left {toLeft}"); + + var wild = default(ManoeuvreTrack); + for (var step = 0; step < 240; step++) wild.Observe(float2(step / 30 % 2 == 0 ? 500 : -500, 0), EvDt, .75f); + var evasion = FireControl.Evasion(forwardOnly, ahead, wild, float2(0, 1), .75f); + Assert.InRange(evasion, .5f * (toRight + toLeft) * .999f, .5f * (toRight + toLeft) * 1.001f); + + var diagonal = default(ManoeuvreTrack); + for (var step = 0; step < 240; step++) + diagonal.Observe(float2(1, 1) * (step / 30 % 2 == 0 ? 40 : -40), EvDt, .75f); + var along = diagonal.LateralRms(normalize(float2(1, 1))); + var across = diagonal.LateralRms(normalize(float2(1, -1))); + Assert.InRange(along, diagonal.LateralRms(float2(1, 0)) * 1.414f * .97f, diagonal.LateralRms(float2(1, 0)) * 1.414f * 1.03f); + Assert.True(across < .05f * along, $"along {along}, across {across}"); + } + // A station or any other non-Ship has no envelope; whatever it does, it does not evade. [Fact] public void NonShipsDoNotEvade() From f9d4f60a9eb4f2a8c755f5fdefaafcd99ffcf1f5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 05:39:39 +0200 Subject: [PATCH 05/29] Thruster.Thrust is read live, so Ship's aggregates stop reading the construction value Co-Authored-By: Claude Opus 5.5 --- .../Scripts/ServerShared/Behaviors/Thruster.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs index ab884ca10..be0d79900 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Thruster.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Thruster.cs @@ -39,7 +39,8 @@ public class ThrusterData : BehaviorData public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { - public float Thrust { get; private set; } + // Live: heat, quality, durability and power move this stat after construction, so it is read, never cached. + public float Thrust => Evaluate(_data.Thrust); public float Torque { get; } public float Axis @@ -67,14 +68,12 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer var itemCenter = hullData.Shape.Inset(itemData.Shape, item.Position, item.EquippableItem.Rotation).CenterOfMass; var toCenter = hullCenter - itemCenter; Torque = -dot(normalize(toCenter), float2(1, 0).Rotate(item.EquippableItem.Rotation)); - Thrust = Evaluate(_data.Thrust); } public Thruster(ThrusterData data, ConsumableItemEffect item) : base(data, item) { _data = data; Torque = 0; - Thrust = Evaluate(_data.Thrust); } // Cut 3 (docs/stats-and-power-cut.md): the resolved stat times the behaviour-supplied throttle scalar (§1.2), @@ -92,8 +91,7 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer public int DefaultPowerTier => PowerTiers.Medium; // What this thruster adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input, read live. - // The Thrust property is refreshed only while the thruster fires, so an idle thruster reports its construction - // value, which is not what it will push with. Its push is Thrust / Mass along its mount, into the matching body + // Its push is Thrust / Mass along its mount, into the matching body // axis, even for a flank thruster that only turns (it pushes whenever it turns); its turn is |Torque| * Thrust * // TorqueMultiplier / Mass into the side its Torque sign fires on, when |Torque| clears the floor. Zero when the // item is offline or unpowered, the same gates Execute applies. @@ -101,7 +99,7 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer { if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default; var settings = ItemManager.GameplaySettings; - var thrust = Evaluate(_data.Thrust); + var thrust = Thrust; var acceleration = thrust / Entity.Mass; var forward = normalize(Entity.Direction); var right = forward.Rotate(ItemRotation.Clockwise); @@ -125,10 +123,10 @@ public class Thruster : Behavior, IAnalogBehavior, IPowerConsumer // has not been cut to true zero supply (the epsilon PowerBus itself already treats as "nothing granted"). if(_input > .01f && Item.PowerSupply > 1e-4f) { - Thrust = Evaluate(_data.Thrust); - Entity.Velocity -= Direction.xz * _input * Thrust / Entity.Mass * dt; + var thrust = Thrust; + Entity.Velocity -= Direction.xz * _input * thrust / Entity.Mass * dt; Entity.Direction = mul(Entity.Direction, - CultMath.float2x2.Rotate(_input * Torque * Thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt)); + CultMath.float2x2.Rotate(_input * Torque * thrust * ItemManager.GameplaySettings.TorqueMultiplier / Entity.Mass * dt)); AddHeat(_input * Evaluate(_data.Heat) * dt); var vis = _input * Evaluate(_data.Visibility); if (!Entity.VisibilitySources.ContainsKey(this) || vis > Entity.VisibilitySources[this]) From 2cf980e3533406716bdb34b82324ef654cd4aefd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 05:39:40 +0200 Subject: [PATCH 06/29] Entity.Acceleration puts back what a VelocityLimit removed: the ceiling is not a change of vector Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/Behaviors/VelocityLimit.cs | 6 +++++- Assets/Scripts/ServerShared/Entity.cs | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/VelocityLimit.cs b/Assets/Scripts/ServerShared/Behaviors/VelocityLimit.cs index 407e5f949..95508ad4e 100644 --- a/Assets/Scripts/ServerShared/Behaviors/VelocityLimit.cs +++ b/Assets/Scripts/ServerShared/Behaviors/VelocityLimit.cs @@ -45,7 +45,11 @@ public class VelocityLimit : Behavior { Limit = Evaluate(_data.TopSpeed); if (length(Entity.Velocity) > Limit) - Entity.Velocity = normalize(Entity.Velocity) * Limit; + { + var clamped = normalize(Entity.Velocity) * Limit; + Entity.LimitClamp += Entity.Velocity - clamped; + Entity.Velocity = clamped; + } return true; } } \ No newline at end of file diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 0e8a35679..db33c321c 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -29,6 +29,11 @@ public abstract class Entity public float2 Acceleration; public ManoeuvreTrack Manoeuvre; + // The planar velocity a VelocityLimit removed during the current base update. A limit is the ship's ceiling, not a + // change of vector the pilot made, so Acceleration reads the velocity change with this put back. VelocityLimit is + // the one writer, Entity.Update zeroes it, and it is never saved. + public float2 LimitClamp; + public float[,] Temperature; public float[,] NewTemperature; public bool2[,] HullConductivity; @@ -1350,6 +1355,7 @@ public abstract class Entity if (!_active) return; var velocityBefore = Velocity; + LimitClamp = float2(0, 0); var hullData = ItemManager.GetData(Hull) as HullData; // Mining Cut 3: a chunk target is lost the moment this entity can no longer see it -- the lost-track rule @@ -1466,7 +1472,7 @@ public abstract class Entity } else Position.y = Zone.GetHeight(Position.xz) + hullData.GridOffset; - Acceleration = delta > 0f ? (Velocity - velocityBefore) / delta : float2(0, 0); + Acceleration = delta > 0f ? (Velocity + LimitClamp - velocityBefore) / delta : float2(0, 0); Manoeuvre.Observe(Acceleration, delta, ItemManager.GameplaySettings.SolutionWindow); } From 522b5af0b49548b3c6cecb4053a342cbe3ec3427 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 05:39:41 +0200 Subject: [PATCH 07/29] 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}"); + } + } } From 46b285016512f4438be1df2921f49993466d6297 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 06:02:34 +0200 Subject: [PATCH 08/29] ASpeedAboveTheLimitIsNotEvasion reads every tick after the clamp: the track forgets within a window Co-Authored-By: Claude Opus 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index b63571c3f..9c87a280b 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -674,12 +674,15 @@ public sealed partial class RunStartTests 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); + // Read every tick: the track forgets within a window, so only the ticks right after the clamp can see it. + for (var step = 0; step < 2 * 60; step++) + { + longinus.Update(EvDt); + 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 From 5f404d509405b21fe40faf7914301405c5ef7ce6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:32:58 +0200 Subject: [PATCH 09/29] EvasionTermTests: pin reach geometry, the oblique line of sight, the track window and forgetting, the drive's inputs and the live-thrust readers Reach against a brute-force turn search (M26) on five envelopes, two windows, an oblique heading; a line of sight at 30/45/120/200 degrees rotated not reflected; evasion from the heading, not the velocity; the entity's track at the solution window; the drive's offline gate and speed efficiency; the forget time as a function of the window, coasting and burning; every Ship thrust aggregate and the strafe compensation against live thrust. TheSpeedDemonNumbers now states what the code gives, to 0.5 percent. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 303 +++++++++++++++++- 1 file changed, 291 insertions(+), 12 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 9c87a280b..1ad806811 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -172,21 +172,23 @@ public sealed partial class RunStartTests float Evade(ManoeuvreEnvelope envelope, float amplitude, float window) => FireControl.Evasion(envelope, noseOn, EvSquare(amplitude, .5f, 4f, EvDt, window), noseOn, window); + // What the code gives (probe at 2910a35d and an independent port of Observe and the box reach, agreeing to 0.01%); + // the map's 5.07, 9.63 and 0.84 are the model's time means and were never produced by this code. The bound is + // 0.5%, so a change to the window, either low-pass constant or the half-width reads as a failure. var demonShort = Evade(demon, 60, .5f); - Assert.InRange(demonShort, 5.07f * .95f, 5.07f * 1.05f); - Assert.InRange(FireControl.PDeviation(demonShort, 10), .49f - .03f, .49f + .03f); - Assert.InRange(FireControl.PDeviation(demonShort, 50), .90f - .03f, .90f + .03f); + Assert.InRange(demonShort, 5.264f * .995f, 5.264f * 1.005f); + Assert.InRange(FireControl.PDeviation(demonShort, 10), .474f - .005f, .474f + .005f); + Assert.InRange(FireControl.PDeviation(demonShort, 50), .895f - .005f, .895f + .005f); var demonLong = Evade(demon, 60, .75f); - // The map's 9.63 is the model's time mean; this reads the track at the instant of the last flip, 5.1% above it. - Assert.InRange(demonLong, 9.63f * .94f, 9.63f * 1.06f); - Assert.InRange(FireControl.PDeviation(demonLong, 10), 0f, .04f + .05f); - Assert.InRange(FireControl.PDeviation(demonLong, 50), .81f - .03f, .81f + .03f); + Assert.InRange(demonLong, 10.116f * .995f, 10.116f * 1.005f); + Assert.Equal(0f, FireControl.PDeviation(demonLong, 10)); + Assert.InRange(FireControl.PDeviation(demonLong, 50), .798f - .005f, .798f + .005f); var heavyShort = Evade(heavy, 10, .5f); - Assert.InRange(heavyShort, .84f * .95f, .84f * 1.05f); - Assert.InRange(FireControl.PDeviation(heavyShort, 10), .92f - .03f, .92f + .03f); - Assert.InRange(FireControl.PDeviation(heavyShort, 50), .98f - .03f, .98f + .03f); + Assert.InRange(heavyShort, .877f * .995f, .877f * 1.005f); + Assert.InRange(FireControl.PDeviation(heavyShort, 10), .912f - .005f, .912f + .005f); + Assert.InRange(FireControl.PDeviation(heavyShort, 50), .983f - .005f, .983f + .005f); } // Reach is what the ship can do: the largest displacement from the coasting path in the window, from the @@ -639,7 +641,7 @@ public sealed partial class RunStartTests // 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. + // drive's own spin-down (a tick or two: the probe reads 0.783 s against a window of 0.75), and stay 0, with the moments back at exactly zero. [Fact] public void AShipThatStopsJinkingStopsEvading() { @@ -660,7 +662,7 @@ public sealed partial class RunStartTests 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.True(zeroAt >= 0f && zeroAt <= window + 3 * EvDt, $"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); @@ -705,4 +707,281 @@ public sealed partial class RunStartTests Assert.True(thruster.Thrust > stat.Min * 1.1f, $"idle thrust {thruster.Thrust} against the stat minimum {stat.Min}"); } } + + // ---- Fix batch B: the inputs and the geometry the first two passes left unpinned. + + // The brute-force search M26 matched Reach against (scratch script, 2026-10-06; docs/aetheria-release-map.md): turn + // monotonically at full rate by some whole number of degrees, then hold, thrusting with the box support of the + // wanted direction throughout; 400 midpoint steps; the best of the 361 turns. `beta` is the wanted direction's angle + // clockwise from the heading. + private static double EvBruteReach(ManoeuvreEnvelope e, double beta, double window) + { + double Support(double g) + { + var c = Math.Cos(g); + var s = Math.Sin(g); + return (c > 0 ? e.Forward * c : -e.Reverse * c) + (s > 0 ? e.Right * s : -e.Left * s); + } + + const int steps = 400; + var dt = window / steps; + var best = 0.0; + for (var degrees = -180; degrees <= 180; degrees++) + { + var delta = degrees * Math.PI / 180; + var rate = delta > 0 ? e.Clockwise : e.CounterClockwise; + var turnTime = rate > 0 ? Math.Abs(delta) / rate : delta == 0 ? 0 : 1e9; + var total = 0.0; + for (var k = 0; k < steps; k++) + { + var t = (k + .5) * dt; + var angle = turnTime > 0 ? delta * Math.Min(1, t / turnTime) : delta; + total += (window - t) * Math.Max(0, Support(beta - angle)) * dt; + } + best = Math.Max(best, total); + } + return best; + } + + // Finding reach-geometry-unpinned: Reach is the largest displacement a turn-then-thrust search finds, to the + // fraction of a percent M26 measured at 0, 45 and 90 degrees, for envelopes whose rates and accelerations all + // differ from one another and from 1 (a rate or an acceleration of 1 hides a divide that should be a multiply), + // two windows, and a heading that is not along an axis. + [Fact] + public void ReachMatchesABruteForceSearchOverTurnStrategies() + { + var envelopes = new[] + { + new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f), new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f), + new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f), new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f), + new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f) + }; + var checkedCases = 0; + foreach (var heading in new[] { float2(0, 1), normalize(float2(2, -1)) }) + { + var forward = normalize(heading); + var right = forward.Rotate(ItemRotation.Clockwise); + foreach (var window in new[] { .5f, .75f }) + foreach (var envelope in envelopes) + foreach (var bearing in new[] { 0f, 45f, 90f, -45f, -90f }) + { + var wanted = forward * cos(radians(bearing)) + right * sin(radians(bearing)); + var reach = FireControl.Reach(envelope, heading, wanted, window); + var brute = EvBruteReach(envelope, bearing * Math.PI / 180, window); + Assert.True(brute > .5, $"fixture: the search finds a reach ({brute})"); + Assert.True(Math.Abs(reach - brute) <= .005 * brute, + $"heading {heading} window {window} bearing {bearing} F{envelope.Forward} R{envelope.Reverse} L{envelope.Left} Rt{envelope.Right} CW{envelope.Clockwise} CCW{envelope.CounterClockwise}: reach {reach:F4}, search {brute:F4}"); + checkedCases++; + } + } + Assert.Equal(2 * 2 * envelopes.Length * 5, checkedCases); + } + + // A track of a ship that changes its vector along `direction` every half second, at 40 m/s^2. + private static ManoeuvreTrack EvAlong(float2 direction, float window) + { + var track = default(ManoeuvreTrack); + for (var step = 0; step < 240; step++) track.Observe(direction * (step / 30 % 2 == 0 ? 40f : -40f), EvDt, window); + return track; + } + + // Finding envelope-inputs-unpinned: the line of sight is rotated to find the axis across it, not reflected. Every other + // test reads a line of sight along an axis, where the two agree. Here the ship changes its vector along a direction + // that is not an axis: seen from a shooter along that direction it evades nothing, from one across it everything the + // track spreads over, and the envelope is roomy enough that the spread, not the reach, is what is read. + [Fact] + public void TheLineOfSightIsRotatedNotReflected() + { + const float window = .75f; + var roomy = new ManoeuvreEnvelope(200, 200, 200, 200, 20, 20); + var ahead = float2(0, 1); + foreach (var degrees in new[] { 30f, 45f, 120f, 200f }) + { + var along = float2(cos(radians(degrees)), sin(radians(degrees))); + var across = float2(-along.y, along.x); + var track = EvAlong(along, window); + var full = .5f * track.LateralRms(along) * window * window; + Assert.True(full > 5f, $"fixture: the jink spreads ({full})"); + Assert.InRange(FireControl.Evasion(roomy, ahead, track, across, window), full * .999f, full * 1.001f); + Assert.True(FireControl.Evasion(roomy, ahead, track, along, window) < .02f * full, + $"{degrees} degrees: along the jink {FireControl.Evasion(roomy, ahead, track, along, window)} against {full}"); + } + } + + // Finding envelope-inputs-unpinned: the capability is the envelope's reach from the ship's heading, not from the + // way it happens to be moving. A forward-heavy Longinus faces one way and drifts another, and the track is so wild + // that the reach is what is read (the spread is thousands of metres). + [Fact] + public void EvasionIsReadFromTheHeadingNotTheVelocity() + { + var (_, longinus, _, _) = EvFleet(); + var window = _items.GameplaySettings.SolutionWindow; + EvSettle(longinus); + longinus.Update(EvDt); + var lineOfSight = normalize(float2(1, 2)); + var across = float2(-lineOfSight.y, lineOfSight.x); + longinus.Direction = across; + longinus.Velocity = lineOfSight * 100f; + var wild = EvAlong(across, window); + wild.Moments *= 1e6f; + longinus.Manoeuvre = wild; + var envelope = longinus.Envelope; + float HalfWidth(float2 heading) => .5f * (FireControl.Reach(envelope, heading, across, window) + FireControl.Reach(envelope, heading, -across, window)); + + var evasion = FireControl.Evasion(longinus, lineOfSight); + Assert.InRange(evasion, HalfWidth(longinus.Direction) * .999f, HalfWidth(longinus.Direction) * 1.001f); + var fromTheVelocity = HalfWidth(normalize(longinus.Velocity)); + Assert.True(Math.Abs(fromTheVelocity - evasion) > .1f * evasion, $"fixture: the two headings read differently ({evasion}, {fromTheVelocity})"); + } + + // Finding envelope-inputs-unpinned: Entity.Update observes the acceleration it measures at the solution window. The + // ship's own track after a two-second jink is the track a reference fed the same accelerations at that window makes. + [Fact] + public void TheShipsTrackObservesAtTheSolutionWindow() + { + var gemini = EvFleet().gemini; + var window = _items.GameplaySettings.SolutionWindow; + EvSettle(gemini); + var reference = gemini.Manoeuvre; + for (var step = 0; step < 120; step++) + { + gemini.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0); + gemini.Update(EvDt); + reference.Observe(gemini.Acceleration, EvDt, window); + } + Assert.True(reference.Moments.x > 1f, $"fixture: the jink is observed ({reference.Moments.x})"); + Assert.InRange(gemini.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f); + Assert.InRange(gemini.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .001f); + Assert.InRange(gemini.Manoeuvre.Trend.x, reference.Trend.x - .001f * Math.Abs(reference.Trend.x) - .001f, reference.Trend.x + .001f * Math.Abs(reference.Trend.x) + .001f); + } + + // Finding envelope-inputs-unpinned: a drive that is offline reports nothing, and a working one reports less along the + // way the ship is already outrunning the rotor: half the rotor's speed forward leaves half the push forward and the + // whole push back, and the same across the ship. + [Fact] + public void TheDrivesReportReadsItsStateAndTheShipsSpeed() + { + var gemini = EvFleet().gemini; + EvSettle(gemini); + gemini.Update(EvDt); + var drive = gemini.GetBehaviors().Single(); + var rest = drive.Manoeuvre(); + Assert.True(rest.Forward > 1f && rest.Reverse > 1f && rest.Left > 1f && rest.Right > 1f && rest.Clockwise > .1f, "fixture: a settled drive pushes every way"); + var rotor = max(drive.Rpm * drive.DriveData.RotorDiameter / 100, 1); + var forward = normalize(gemini.Direction); + var right = EvRight(gemini); + + gemini.Velocity = forward * (.5f * rotor.x); + var ahead = drive.Manoeuvre(); + Assert.InRange(ahead.Forward / rest.Forward, .49f, .51f); + Assert.InRange(ahead.Reverse / rest.Reverse, .99f, 1.01f); + gemini.Velocity = -forward * (.5f * rotor.x); + var astern = drive.Manoeuvre(); + Assert.InRange(astern.Reverse / rest.Reverse, .49f, .51f); + Assert.InRange(astern.Forward / rest.Forward, .99f, 1.01f); + gemini.Velocity = right * (.5f * rotor.y); + var starboard = drive.Manoeuvre(); + Assert.InRange(starboard.Right / rest.Right, .49f, .51f); + Assert.InRange(starboard.Left / rest.Left, .99f, 1.01f); + gemini.Velocity = -right * (.5f * rotor.y); + var port = drive.Manoeuvre(); + Assert.InRange(port.Left / rest.Left, .49f, .51f); + Assert.InRange(port.Right / rest.Right, .99f, 1.01f); + + gemini.Velocity = float2(0, 0); + drive.Item.EquippableItem.Durability = 0f; + gemini.Update(EvDt); + gemini.Update(EvDt); + Assert.False(drive.Item.Active.Value, "fixture: the drive is offline"); + var offline = drive.Manoeuvre(); + Assert.Equal(0f, offline.Forward + offline.Reverse + offline.Left + offline.Right + offline.Clockwise + offline.CounterClockwise); + } + + // The pure track forgets: after jinking, a track fed a constant acceleration (none at all, or a steady burn) reads + // exactly zero from the first tick at which the acceleration has held for the whole window, and not before, for each + // window. The change tick itself is tick 0. + private static int EvTicksToForget(float window, float2 held) + { + var track = EvAlong(float2(1, 0), window); + Assert.True(track.Moments.x > 1f, "fixture: the jink is observed"); + var first = -1; + for (var tick = 0; tick < 300; tick++) + { + track.Observe(held, EvDt, window); + var zero = track.Moments.x == 0f && track.Moments.y == 0f && track.Moments.z == 0f && track.Innovation.x == 0f && track.Innovation.y == 0f; + if (first < 0 && zero) first = tick; + if (first >= 0) Assert.True(zero, $"window {window}: the track woke at tick {tick} after forgetting at {first}"); + } + return first; + } + + // Findings forget-window-unpinned and steady-burn-forget-unpinned: how long the track waits before it forgets is the + // window, whatever the window is, coasting or burning. + [Fact] + public void ATrackForgetsAWindowAfterTheLastChangeOfVector() + { + foreach (var window in new[] { .4f, .75f, 1.5f }) + foreach (var held in new[] { float2(0, 0), float2(15, -8) }) + { + var first = EvTicksToForget(window, held); + var ticks = (int) Math.Ceiling(window / EvDt); + Assert.True(first >= ticks - 1 && first <= ticks + 1, $"window {window}, held {held}: forgot at tick {first}, expected about {ticks}"); + } + } + + // Finding strafe-readers-unpinned: every one of Ship's thrust aggregates and the strafe torque compensation reads the + // live thrust, not the stat minimum a thruster had at construction. The idle Djinni's aggregates are the sums of what + // its thrusters push with now, group by group. + [Fact] + public void EveryAggregateAndTheCompensationReadLiveThrust() + { + var (_, _, djinni, _) = EvFleet(); + EvSettle(djinni); + djinni.Update(EvDt); + Assert.Empty(djinni.GetBehaviors()); + var thrusters = djinni.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); + float Live(Thruster t) => t.Evaluate(((ThrusterData) t.Data).Thrust); + float Mount(ItemRotation rotation, Func value) => thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).Sum(value); + foreach (var rotation in new[] { ItemRotation.None, ItemRotation.Clockwise, ItemRotation.CounterClockwise }) + { + var group = thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).ToList(); + Assert.NotEmpty(group); + Assert.All(group, t => Assert.True(Live(t) > ((ThrusterData) t.Data).Thrust.Min * 1.1f, $"fixture: {rotation} thruster thrust {Live(t)} above its stat minimum")); + } + + void Near(float expected, float actual) => Assert.InRange(actual, expected * .999f - 1e-3f, expected * 1.001f + 1e-3f); + Near(Mount(ItemRotation.None, Live), djinni.ReverseThrust); + Near(Mount(ItemRotation.Clockwise, Live), djinni.LeftStrafeThrust); + Near(Mount(ItemRotation.CounterClockwise, Live), djinni.RightStrafeThrust); + Near(Mount(ItemRotation.Clockwise, t => t.Torque * Live(t)), djinni.LeftStrafeTotalTorque); + Near(Mount(ItemRotation.CounterClockwise, t => t.Torque * Live(t)), djinni.RightStrafeTotalTorque); + + // The compensation trims each torque thruster of a strafing side by the side's mean torque, over that thruster's + // own torque at its live thrust. + foreach (var (side, field, direction, total) in new[] + { + (ItemRotation.CounterClockwise, "RightStrafeTorqueThrusters", 1f, djinni.RightStrafeTotalTorque), + (ItemRotation.Clockwise, "LeftStrafeTorqueThrusters", -1f, djinni.LeftStrafeTotalTorque) + }) + { + var members = (List) typeof(Ship).GetField(field, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(djinni); + Assert.NotEmpty(members); + djinni.MovementDirection = float2(direction, 0); + djinni.LookDirection = float3(0, 0, 1); + djinni.Update(EvDt); + var mean = Math.Abs(total) / members.Count; + var trimmed = 0; + foreach (var thruster in thrusters.Where(t => t.Item.EquippableItem.Rotation == side)) + { + var expected = members.Contains(thruster) ? 1f - mean / (Math.Abs(thruster.Torque) * Live(thruster)) : 1f; + expected = Math.Max(0f, Math.Min(1f, expected)); + if (expected < .99f && expected > .01f) trimmed++; + Assert.InRange(thruster.Axis, expected - .002f, expected + .002f); + } + Assert.True(trimmed > 0, $"fixture: the {side} compensation trims a thruster to somewhere between off and full"); + djinni.MovementDirection = float2(0, 0); + EvSettle(djinni); + djinni.Update(EvDt); + } + } } From 0ca7a7c98ed482cb0e3cd3dc5694b0ffdf8b31c4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:39:58 +0200 Subject: [PATCH 10/29] EvasionTermTests: the forgetting probe jinks inside the window; the aggregate tolerance reads a negative torque Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 1ad806811..358b72169 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -777,11 +777,11 @@ public sealed partial class RunStartTests Assert.Equal(2 * 2 * envelopes.Length * 5, checkedCases); } - // A track of a ship that changes its vector along `direction` every half second, at 40 m/s^2. + // A track of a ship that changes its vector along `direction` every fifth of a second, inside every window the tests use, at 60 m/s^2, for four seconds. private static ManoeuvreTrack EvAlong(float2 direction, float window) { var track = default(ManoeuvreTrack); - for (var step = 0; step < 240; step++) track.Observe(direction * (step / 30 % 2 == 0 ? 40f : -40f), EvDt, window); + for (var step = 0; step < 240; step++) track.Observe(direction * (step / 12 % 2 == 0 ? 60f : -60f), EvDt, window); return track; } @@ -949,7 +949,7 @@ public sealed partial class RunStartTests Assert.All(group, t => Assert.True(Live(t) > ((ThrusterData) t.Data).Thrust.Min * 1.1f, $"fixture: {rotation} thruster thrust {Live(t)} above its stat minimum")); } - void Near(float expected, float actual) => Assert.InRange(actual, expected * .999f - 1e-3f, expected * 1.001f + 1e-3f); + void Near(float expected, float actual) => Assert.InRange(actual, expected - .001f * Math.Abs(expected) - 1e-3f, expected + .001f * Math.Abs(expected) + 1e-3f); Near(Mount(ItemRotation.None, Live), djinni.ReverseThrust); Near(Mount(ItemRotation.Clockwise, Live), djinni.LeftStrafeThrust); Near(Mount(ItemRotation.CounterClockwise, Live), djinni.RightStrafeThrust); From e494454941b692145061e036e3cd1e64bb241082 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 12:47:31 +0200 Subject: [PATCH 11/29] EvasionTermTests: the diagonal jink fixture asks for a spread of 3 m, not 5 (it reads 4.997) Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 358b72169..808ccb159 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -801,7 +801,7 @@ public sealed partial class RunStartTests var across = float2(-along.y, along.x); var track = EvAlong(along, window); var full = .5f * track.LateralRms(along) * window * window; - Assert.True(full > 5f, $"fixture: the jink spreads ({full})"); + Assert.True(full > 3f, $"fixture: the jink spreads ({full})"); Assert.InRange(FireControl.Evasion(roomy, ahead, track, across, window), full * .999f, full * 1.001f); Assert.True(FireControl.Evasion(roomy, ahead, track, along, window) < .02f * full, $"{degrees} degrees: along the jink {FireControl.Evasion(roomy, ahead, track, along, window)} against {full}"); From 78daa12db55a106cd610bc407e5e0028ab70621f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 14:40:41 +0200 Subject: [PATCH 12/29] EvasionTermTests: reach all the way round and for a ship that cannot turn, the face form past a right angle, the degenerate guards of the term and the track Stryker on the batch's ranges left the corner faces, the wrap, the no-turn box support, the time before a face may thrust, the zero line of sight and heading, the Observe guards and thresholds, and a step of no time alive; each is pinned here. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 110 ++++++++++++++++-- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 808ccb159..d83f44a8b 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -746,24 +746,35 @@ public sealed partial class RunStartTests // Finding reach-geometry-unpinned: Reach is the largest displacement a turn-then-thrust search finds, to the // fraction of a percent M26 measured at 0, 45 and 90 degrees, for envelopes whose rates and accelerations all // differ from one another and from 1 (a rate or an acceleration of 1 hides a divide that should be a multiply), - // two windows, and a heading that is not along an axis. + // two windows, and a heading that is not along an axis. The same holds all the way round for the envelopes below + // (the corner faces and the wrap past 180 degrees), and for a ship that cannot turn at all, where the answer is + // the box support with no turn. The case list was fixed from a Python port of the search before any C# read it. [Fact] public void ReachMatchesABruteForceSearchOverTurnStrategies() { - var envelopes = new[] + var gemini = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f); + var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f); + var lopsided = new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f); + var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f); + var slow = new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f); + var cannotTurn = new ManoeuvreEnvelope(20, 10, 5, 15, 0, 0); + var quarter = new[] { 0f, 45f, 90f, -45f, -90f }; + var round = new[] { 135f, -135f, 180f, 100f, -100f }; + var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[] { - new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f), new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f), - new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f), new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f), - new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f) + (gemini, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter), + (forwardOnly, new[] { .5f, .75f }, quarter), (slow, new[] { .5f, .75f }, quarter), + (gemini, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()), + (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }) }; var checkedCases = 0; foreach (var heading in new[] { float2(0, 1), normalize(float2(2, -1)) }) { var forward = normalize(heading); var right = forward.Rotate(ItemRotation.Clockwise); - foreach (var window in new[] { .5f, .75f }) - foreach (var envelope in envelopes) - foreach (var bearing in new[] { 0f, 45f, 90f, -45f, -90f }) + foreach (var (envelope, windows, bearings) in cases) + foreach (var window in windows) + foreach (var bearing in bearings) { var wanted = forward * cos(radians(bearing)) + right * sin(radians(bearing)); var reach = FireControl.Reach(envelope, heading, wanted, window); @@ -774,7 +785,88 @@ public sealed partial class RunStartTests checkedCases++; } } - Assert.Equal(2 * 2 * envelopes.Length * 5, checkedCases); + Assert.Equal(2 * cases.Sum(c => c.windows.Length * c.bearings.Length), checkedCases); + } + + // Where a ship must turn more than a right angle before a face points the way it wants to go, the eight-face form + // undershoots the search (the Longinus, which cannot thrust backwards: 3% at 135 degrees in half a second, 16% in + // three quarters). The values are the faces' own, from the Python port of the form (evasion_model.reach_faces), and + // pin the time a face spends turning before it may thrust. + [Fact] + public void ReachPastARightAngleOfTurnIsTheFaceFormsOwn() + { + var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f); + var heading = normalize(float2(2, -1)); + var right = heading.Rotate(ItemRotation.Clockwise); + foreach (var (window, bearing, expected) in new[] { (.5f, 135f, 2.5511f), (.75f, 135f, 5.9491f), (.5f, 100f, 3.9492f), (.75f, -100f, 10.4735f), (.5f, -135f, 2.5511f) }) + { + var reach = FireControl.Reach(longinus, heading, heading * cos(radians(bearing)) + right * sin(radians(bearing)), window); + Assert.InRange(reach, expected * .995f, expected * 1.005f); + } + } + + // A line of sight or a heading of no length has no across and no reach: the term falls back to a sight along +y for + // the first and reads nothing for the second. + [Fact] + public void ADegenerateLineOfSightOrHeadingIsHandled() + { + const float window = .75f; + var roomy = new ManoeuvreEnvelope(200, 200, 200, 200, 20, 20); + var track = EvAlong(float2(1, 0), window); + var fallback = FireControl.Evasion(roomy, float2(0, 1), track, float2(0, 0), window); + var along = FireControl.Evasion(roomy, float2(0, 1), track, float2(0, 1), window); + Assert.True(along > 3f, $"fixture: the jink spreads across +y ({along})"); + Assert.InRange(fallback, along * .999f, along * 1.001f); + Assert.Equal(0f, FireControl.Reach(roomy, float2(0, 0), float2(1, 0), window)); + Assert.Equal(0f, FireControl.Reach(roomy, float2(0, 1), float2(0, 0), window)); + } + + // The track's own guards: a step of no time or a window of none leaves the track as it was, a change of exactly the + // threshold is not a change, and the track forgets on the very tick the quiet reaches the window (a quarter-second + // step and a one second window are exact in floats). + [Fact] + public void ATrackCountsQuietExactlyAndIgnoresDegenerateSteps() + { + var jinked = EvAlong(float2(1, 0), .75f); + var before = jinked; + jinked.Observe(float2(30, -4), 0f, .75f); + jinked.Observe(float2(30, -4), EvDt, 0f); + Assert.Equal(before.Last, jinked.Last); + Assert.Equal(before.Quiet, jinked.Quiet); + Assert.Equal(before.Moments, jinked.Moments); + + var held = default(ManoeuvreTrack); + held.Observe(float2(ManoeuvreTrack.ChangeThreshold, 0), EvDt, .75f); + Assert.Equal(EvDt, held.Quiet); + held.Observe(float2(ManoeuvreTrack.ChangeThreshold, ManoeuvreTrack.ChangeThreshold), EvDt, .75f); + Assert.Equal(2 * EvDt, held.Quiet); + held.Observe(float2(.5f, ManoeuvreTrack.ChangeThreshold), EvDt, .75f); + Assert.Equal(0f, held.Quiet); + + var coarse = default(ManoeuvreTrack); + for (var tick = 0; tick < 16; tick++) coarse.Observe(float2(tick % 2 == 0 ? 40f : -40f, 0), .25f, 1f); + Assert.True(coarse.Moments.x > 1f, "fixture: the jink is observed"); + var first = -1; + for (var tick = 0; tick < 8; tick++) + { + coarse.Observe(float2(0, 0), .25f, 1f); + if (first < 0 && coarse.Moments.x == 0f) first = tick; + } + Assert.Equal(4, first); + } + + // The acceleration of a step of no time is none, not a division by zero. + [Fact] + public void AStepOfNoTimeHasNoAcceleration() + { + var (_, longinus, _, _) = EvFleet(); + EvSettle(longinus); + longinus.MovementDirection = float2(0, 1); + for (var step = 0; step < 10; step++) longinus.Update(EvDt); + Assert.True(longinus.Acceleration.y > 1f, "fixture: it is burning"); + longinus.Update(0f); + Assert.Equal(0f, longinus.Acceleration.x); + Assert.Equal(0f, longinus.Acceleration.y); } // A track of a ship that changes its vector along `direction` every fifth of a second, inside every window the tests use, at 60 m/s^2, for four seconds. From ee5f5cbb49315a8280c678097d67c9be12991572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:19:49 +0200 Subject: [PATCH 13/29] EvasionTermTests: reach for ships that can thrust one way only, over windows long enough that the long way round is best Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index d83f44a8b..e603b0afe 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -758,6 +758,8 @@ public sealed partial class RunStartTests var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f); var slow = new ManoeuvreEnvelope(10, 10, 10, 10, .8f, .8f); var cannotTurn = new ManoeuvreEnvelope(20, 10, 5, 15, 0, 0); + var leftOnly = new ManoeuvreEnvelope(0, 0, 40, 0, 3, 1.5f); + var rightOnly = new ManoeuvreEnvelope(0, 0, 0, 40, 1.5f, 3); var quarter = new[] { 0f, 45f, 90f, -45f, -90f }; var round = new[] { 135f, -135f, 180f, 100f, -100f }; var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[] @@ -765,7 +767,12 @@ public sealed partial class RunStartTests (gemini, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter), (forwardOnly, new[] { .5f, .75f }, quarter), (slow, new[] { .5f, .75f }, quarter), (gemini, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()), - (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }) + (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }), + // Ships that can thrust one way only, over windows long enough for every face to finish its turn: the face that + // must turn the long way round, past a right angle and past the wrap at 180 degrees, is the best one. + (forwardOnly, new[] { 1f, 1.5f, 2f }, new[] { 180f, 135f, -135f, 170f, 120f, -120f }), + (leftOnly, new[] { 1f, 1.5f }, new[] { 180f, 135f, -135f, 170f, -170f, -120f, -100f }), + (rightOnly, new[] { 1f, 1.5f }, new[] { 180f, 135f, -135f, 170f, -170f, 120f, 100f }) }; var checkedCases = 0; foreach (var heading in new[] { float2(0, 1), normalize(float2(2, -1)) }) From 593958ec2d05a958f0c8ac5435e5cd15add7a7d5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:21:41 +0200 Subject: [PATCH 14/29] EvasionTermTests: one-way ships are probed at 160 degrees, not the knife-edge at 180 where the two turn directions tie Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index e603b0afe..41b6b3cf6 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -770,9 +770,9 @@ public sealed partial class RunStartTests (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }), // Ships that can thrust one way only, over windows long enough for every face to finish its turn: the face that // must turn the long way round, past a right angle and past the wrap at 180 degrees, is the best one. - (forwardOnly, new[] { 1f, 1.5f, 2f }, new[] { 180f, 135f, -135f, 170f, 120f, -120f }), - (leftOnly, new[] { 1f, 1.5f }, new[] { 180f, 135f, -135f, 170f, -170f, -120f, -100f }), - (rightOnly, new[] { 1f, 1.5f }, new[] { 180f, 135f, -135f, 170f, -170f, 120f, 100f }) + (forwardOnly, new[] { 1f, 1.5f, 2f }, new[] { 160f, 135f, -135f, 170f, 120f, -120f }), + (leftOnly, new[] { 1f, 1.5f }, new[] { 160f, 135f, -135f, 170f, -170f, -120f, -100f }), + (rightOnly, new[] { 1f, 1.5f }, new[] { 160f, 135f, -135f, 170f, -170f, 120f, 100f }) }; var checkedCases = 0; foreach (var heading in new[] { float2(0, 1), normalize(float2(2, -1)) }) From e360ceeb27fe613e14d09cc537b36d8019995e06 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:39:45 +0200 Subject: [PATCH 15/29] Evasion r6: drop the aether drive from the envelope; the gemini fixtures fly thruster hulls Ruling retire-longinusx. AetherDrive.Manoeuvre and the drive line in Ship.RecalculateEnvelope go: the envelope sums thrusters only. TheDrivesReportReadsItsStateAndTheShipsSpeed, the EvGemini fixture and the drive-only asserts are deleted. Sideways jinks and the shooters' target move to the generated Djinni; the gun platforms (FastBlast+-, ColdFire) are EvLonginus, the Duel's guns on a Longinus. TheThreeShipsOrder becomes TheShipsOrder. Not built or run on its own: verified together with the mount term at the head. Co-Authored-By: Claude Sonnet 5.5 --- .../ServerShared/Behaviors/AetherDrive.cs | 25 -- Assets/Scripts/ServerShared/Ship.cs | 3 +- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 222 ++++++------------ 3 files changed, 77 insertions(+), 173 deletions(-) diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index 84c6ea481..81ee4471a 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -126,31 +126,6 @@ public class AetherDrive : Behavior, IPowerConsumer // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster. public int DefaultPowerTier => PowerTiers.Medium; - // What this drive adds to the ship's manoeuvre envelope: Execute's own arithmetic at full input on every axis, - // as a rate per second at the rotors' current state (the rotors spend what they hold, so a drive that has been - // thrusting reports less). Each axis delivers Rpm * lambda * RotorMass * efficiency / Mass, the efficiency - // falling as the ship's speed along that axis approaches the rotor speed, so the push along the current - // velocity is smaller than against it; the turn is the z axis through AetherTorqueMultiplier, the same both - // ways. Zero when the item is offline or unpowered, the gates Execute applies. - public ManoeuvreEnvelope Manoeuvre() - { - if (Item == null || !Item.Active.Value || Item.PowerSupply <= 1e-4f) return default; - var forward = normalize(Entity.Direction); - var right = forward.Rotate(ItemRotation.Clockwise); - var speed = float2(dot(Entity.Velocity, forward), dot(Entity.Velocity, right)); - var rotorSpeed = max(Rpm * _data.RotorDiameter / 100, 1); - var couplingEfficiency = Evaluate(_data.CouplingEfficiency); - var perSecond = Rpm * _data.CouplingLambda * Item.Evaluate(_data.LambdaMultiplier) * _data.RotorMass / Entity.Mass; - float Push(float axisRate, float axisSpeed, float axisRotorSpeed, float direction) => - axisRate * saturate(1 - axisSpeed / axisRotorSpeed * direction) * couplingEfficiency; - return new ManoeuvreEnvelope( - Push(perSecond.x, speed.x, rotorSpeed.x, 1), Push(perSecond.x, speed.x, rotorSpeed.x, -1), - Push(perSecond.y, speed.y, rotorSpeed.y, -1), Push(perSecond.y, speed.y, rotorSpeed.y, 1), - perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier, - perSecond.z * ItemManager.GameplaySettings.AetherTorqueMultiplier); - } - - public override bool Execute(float dt) { var rotorSpeed = Rpm * _data.RotorDiameter / 100; diff --git a/Assets/Scripts/ServerShared/Ship.cs b/Assets/Scripts/ServerShared/Ship.cs index 7bccbd803..44c388ad7 100644 --- a/Assets/Scripts/ServerShared/Ship.cs +++ b/Assets/Scripts/ServerShared/Ship.cs @@ -145,7 +145,7 @@ public class Ship : Entity #region ThrustCalculation // What this ship can do at this instant (see ManoeuvreEnvelope): the sum of what each live propulsor reports with - // its own Execute arithmetic. Derived each update, never saved. The aggregates above are not an envelope (they + // its own Execute arithmetic; thrusters are its only propulsors. Derived each update, never saved. The aggregates above are not an envelope (they // mix units and cancel a lone off-axis strafer), so nothing in it reads them. public ManoeuvreEnvelope Envelope { get; private set; } @@ -153,7 +153,6 @@ public class Ship : Entity { var envelope = default(ManoeuvreEnvelope); foreach (var thruster in _allThrusters) envelope += thruster.Manoeuvre(); - foreach (var drive in _aetherDrives) envelope += drive.Manoeuvre(); Envelope = envelope; } diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 41b6b3cf6..a5f3fd427 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -16,25 +16,27 @@ using int2 = CultMath.int2; // The evasion term (docs/aetheria-release-map.md, "Evasion is how unpredictably the target can and does change its // motion"): how far a target can and does move off the line of fire in the solution window, one factor of every hit // price. The fixture is RunStartTests': the shipped catalog and the authored settings, so the ships fly in the real -// Ship.Update with the real thrusters and aether drive. Ship flights step the ships directly (they are alone in +// Ship.Update with the real thrusters. Ship flights step the ships directly (they are alone in // their arena); the one price test that rolls shots steps only FireControl. public sealed partial class RunStartTests { private const float EvDt = 1f / 60f; - // The Duel's LonginusX fit: one Traction aether drive that pushes all four ways, with its guns. - private static ScenarioFit EvGemini(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("LonginusX", - ("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Traction", int2(2, 3), ItemRotation.None), ("Core Power", int2(2, 1), ItemRotation.None), + // The Duel's guns on a Longinus: the generated Longinus's thrusters (two Large Drive forward, two Talaria on the + // flanks) and power, the Duel's cockpit, guns, launchers and sensor, and the Fire Control Array as its targeting gear. + private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("Longinus", + ("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Core Power", int2(2, 4), ItemRotation.None), + ("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Large Drive", int2(3, 0), ItemRotation.Reversed), + ("Talaria", int2(2, 14), ItemRotation.CounterClockwise), ("Talaria", int2(3, 14), ItemRotation.Clockwise), ("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None), - (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(1, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(4, 2), ItemRotation.Clockwise), - ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 5), ItemRotation.None), - ("Fire Control Array", int2(2, 5), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); + (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise), + ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 6), ItemRotation.None), + ("Fire Control Array", int2(1, 10), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); - // The catalog's three fitted ships, far apart in the fixture arena: the generated Longinus and Djinni at the - // fixture seed and the omnidirectional LonginusX. - private (Zone arena, Ship longinus, Ship djinni, Ship gemini) EvFleet() + // The catalog's two generated ships, far apart in the fixture arena, at the fixture seed. + private (Zone arena, Ship longinus, Ship djinni) EvFleet() { - Ship longinus = null, djinni = null, gemini = null; + Ship longinus = null, djinni = null; var scenario = new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); @@ -42,17 +44,15 @@ public sealed partial class RunStartTests // first, then the Longinus, as the map's probe did (M23). djinni = stage.Place(stage.Generated("Djinni"), float2(-10000, -30000), facing: float2(0, 1)) as Ship; longinus = stage.Place(stage.Generated("Longinus"), float2(-30000, -30000), facing: float2(0, 1)) as Ship; - gemini = stage.Place(EvGemini(stage), float2(10000, -30000), facing: float2(0, 1)) as Ship; }); var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); Assert.NotNull(longinus); Assert.NotNull(djinni); - Assert.NotNull(gemini); - return (arena, longinus, djinni, gemini); + return (arena, longinus, djinni); } - // Idle long enough for stats, power and the aether rotors to reach their idle state, then at rest, facing +y, + // Idle long enough for stats and power to reach their idle state, then at rest, facing +y, // and, after three seconds of coasting, with nothing observed: the track forgets on its own. private static void EvSettle(Ship ship) { @@ -117,8 +117,7 @@ public sealed partial class RunStartTests foreach (var jink in new[] { EvJink.Lateral, EvJink.Longitudinal, EvJink.Random, EvJink.Weave }) foreach (var halfPeriod in new[] { .5f, 1f }) { - // A fresh ship each flight where the caller wants one: an aether drive heats and spends its rotors, and a - // drive that has run itself offline has no envelope. + // A fresh ship each flight where the caller wants one: a ship keeps the track its last flight left. var ship = flightShip(); EvSettle(ship); EvFly(ship, jink, halfPeriod, 4f); @@ -132,19 +131,17 @@ public sealed partial class RunStartTests return (noseOn, broadside); } - // Ruling evasion-is-unpredictability: the Gemini is evasive because it can floor it in any direction at any time, - // the Longinus needs to aim itself where it wants to go, and the Djinni is somewhere between. + // Ruling evasion-is-unpredictability: a ship is evasive by what it can do to its motion at any time. The Longinus has + // to aim itself where it wants to go and the Djinni is a lighter ship of small thrusters. [Fact] - public void TheThreeShipsOrder() + public void TheShipsOrder() { - var (_, longinus, djinni, _) = EvFleet(); - var g = EvBest(() => EvFleet().gemini); + var (_, longinus, djinni) = EvFleet(); var l = EvBest(() => longinus); var d = EvBest(() => djinni); - Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: gemini-like nose-on {g.noseOn:F2} broadside {g.broadside:F2}; " + - $"longinus {l.noseOn:F2} {l.broadside:F2}; djinni {d.noseOn:F2} {d.broadside:F2}"); - Assert.True(g.noseOn > l.noseOn && l.noseOn > d.noseOn, $"nose-on order: {g.noseOn} > {l.noseOn} > {d.noseOn}"); - Assert.True(g.broadside > l.broadside && l.broadside > d.broadside, $"broadside order: {g.broadside} > {l.broadside} > {d.broadside}"); + Console.WriteLine($"EVASION window {_items.GameplaySettings.SolutionWindow}: longinus {l.noseOn:F2} {l.broadside:F2}; djinni {d.noseOn:F2} {d.broadside:F2}"); + Assert.True(l.noseOn > d.noseOn, $"nose-on order: {l.noseOn} > {d.noseOn}"); + Assert.True(l.broadside > d.broadside, $"broadside order: {l.broadside} > {d.broadside}"); // Re-measured (M25): the Djinni's thrusters push 1.7 times harder than the figure the map priced it with, so // it evades more than the map's 1.7 m and the Longinus leads it by less than the map's factor of eight. Assert.True(l.noseOn >= 2 * d.noseOn && l.broadside >= 2 * d.broadside, "the Longinus evades at least twice the Djinni"); @@ -248,7 +245,7 @@ public sealed partial class RunStartTests [Fact] public void ReachIsWhatTheShipCanDo() { - var (_, longinus, djinni, _) = EvFleet(); + var (_, longinus, djinni) = EvFleet(); const float window = .5f; var results = new List<(string name, (float noseOn, float broadside) reach, (float noseOn, float broadside) measured)>(); foreach (var (name, ship) in new[] { ("longinus", longinus), ("djinni", djinni) }) @@ -278,7 +275,7 @@ public sealed partial class RunStartTests [Fact] public void TheEnvelopeComesFromTheGear() { - var (_, longinus, _, gemini) = EvFleet(); + var (_, longinus, _) = EvFleet(); EvSettle(longinus); // A thruster's Thrust property is refreshed only while it fires: turn each way so the flank thrusters hold what // they push with, then read the envelope at rest. @@ -314,13 +311,6 @@ public sealed partial class RunStartTests longinus.Update(EvDt); longinus.Update(EvDt); Assert.InRange(longinus.Envelope.Forward, envelope.Forward * .49f, envelope.Forward * .51f); - - // The LonginusX's Traction drive pushes and turns every way. - EvSettle(gemini); - gemini.Update(EvDt); - var drive = gemini.Envelope; - Assert.True(drive.Forward > 1f && drive.Reverse > 1f && drive.Left > 1f && drive.Right > 1f, $"traction: {drive.Forward} {drive.Reverse} {drive.Left} {drive.Right}"); - Assert.True(drive.Clockwise > 0f && drive.CounterClockwise > 0f); } // Which thruster pushes which way and turns which way is read from the gear, not assumed symmetric: destroy the @@ -329,7 +319,7 @@ public sealed partial class RunStartTests [Fact] public void TheEnvelopeFollowsWhichThrustersAreLeft() { - var (_, _, djinni, gemini) = EvFleet(); + var (_, _, djinni) = EvFleet(); var settings = _items.GameplaySettings; EvSettle(djinni); // Refresh every thruster's live Thrust by firing it: strafe, burn and turn each way. @@ -370,14 +360,6 @@ public sealed partial class RunStartTests djinni.MovementDirection = float2(-1, 0); djinni.Update(EvDt); Assert.True(djinni.Acceleration.x < -1f, "it still strafes left"); - - // The Traction drive's turn is the rate it turns the hull at when told to turn right. - EvSettle(gemini); - gemini.MovementDirection = float2(0, 0); - gemini.LookDirection = float3(1, 0, 0); - gemini.Update(EvDt); - var turned = Math.Atan2(gemini.Direction.x, gemini.Direction.y); - Assert.InRange((float) turned / EvDt, gemini.Envelope.Clockwise * .9f, gemini.Envelope.Clockwise * 1.1f); } // The reach and the evasion read each side of the envelope for themselves: a forward-only ship that turns clockwise @@ -425,7 +407,7 @@ public sealed partial class RunStartTests [Fact] public void ManoeuvreIsObserved() { - var (_, longinus, djinni, gemini) = EvFleet(); + var (_, longinus, djinni) = EvFleet(); // Coasting: no acceleration, an all-zero track. EvSettle(longinus); @@ -449,15 +431,6 @@ public sealed partial class RunStartTests djinni.Update(EvDt); Assert.InRange(djinni.Acceleration.y, djinni.Envelope.Forward * .98f, djinni.Envelope.Forward * 1.02f); - // Strafing at full thrust for one update on the Traction drive: the drive's own push over the step. - EvSettle(gemini); - gemini.MovementDirection = float2(1, 0); - gemini.Update(EvDt); - var drive = gemini.GetBehaviors().Single(); - Assert.True(drive.ThrustDirection.x > 0f); - Assert.InRange(gemini.Acceleration.x, drive.ThrustDirection.x / EvDt * .999f, drive.ThrustDirection.x / EvDt * 1.001f); - Assert.InRange(gemini.Acceleration.x, gemini.Envelope.Right * .95f, gemini.Envelope.Right * 1.05f); - // The same jink observed at two step sizes reads the same unpredictability. var fine = EvSquare(40, .5f, 4f, 1f / 60f, .75f); var coarse = EvSquare(40, .5f, 4f, 1f / 30f, .75f); @@ -471,34 +444,34 @@ public sealed partial class RunStartTests public void ASteadyBurnAndDitheringAreNotEvasion() { var window = _items.GameplaySettings.SolutionWindow; - // A fresh ship for each phase: an aether drive heats and spends its rotors as it flies. - var gemini = EvFleet().gemini; - float Nose() => FireControl.Evasion(gemini, normalize(gemini.Direction)); + // A fresh ship for each phase: a ship keeps the track and the heat its last flight left. + var djinni = EvFleet().djinni; + float Nose() => FireControl.Evasion(djinni, normalize(djinni.Direction)); - EvSettle(gemini); - EvFly(gemini, EvJink.Lateral, .5f, 4f); + EvSettle(djinni); + EvFly(djinni, EvJink.Lateral, .5f, 4f); var jinking = Nose(); Assert.True(jinking > 1f, $"fixture: the jink evades ({jinking})"); - gemini = EvFleet().gemini; - EvSettle(gemini); - gemini.MovementDirection = float2(0, 1); - for (var step = 0; step < (int) Math.Round(3 * window / EvDt); step++) gemini.Update(EvDt); + djinni = EvFleet().djinni; + EvSettle(djinni); + djinni.MovementDirection = float2(0, 1); + for (var step = 0; step < (int) Math.Round(3 * window / EvDt); step++) djinni.Update(EvDt); Assert.True(Nose() < .05f * jinking, $"a steady burn: {Nose()} against {jinking}"); - gemini = EvFleet().gemini; - EvSettle(gemini); - EvFly(gemini, EvJink.Lateral, .05f, 4f); + djinni = EvFleet().djinni; + EvSettle(djinni); + EvFly(djinni, EvJink.Lateral, .05f, 4f); Assert.True(Nose() < .25f * jinking, $"a dither: {Nose()} against {jinking}"); - gemini = EvFleet().gemini; - EvSettle(gemini); - for (var step = 0; step < 240; step++) gemini.Update(EvDt); + djinni = EvFleet().djinni; + EvSettle(djinni); + for (var step = 0; step < 240; step++) djinni.Update(EvDt); Assert.Equal(0f, Nose()); } // Two shooters on opposite sides of the same target, 200 m away: one with a projectile gun and one with a laser - // (a beam's authored velocity is 0, a flight time of zero). The target is the omnidirectional LonginusX. + // (a beam's authored velocity is 0, a flight time of zero). The target is the generated Djinni, the catalog's one ship that strafes. private sealed class EvRange { public Zone Arena; @@ -512,9 +485,9 @@ public sealed partial class RunStartTests var scenario = new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - gunner = stage.Place(EvGemini(stage), float2(0, 0), facing: float2(0, 1)) as Ship; - target = stage.Place(EvGemini(stage), float2(0, 200), facing: float2(0, 1)) as Ship; - lasing = stage.Place(EvGemini(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship; + gunner = stage.Place(EvLonginus(stage), float2(0, 0), facing: float2(0, 1)) as Ship; + target = stage.Place(stage.Generated("Djinni"), float2(0, 200), facing: float2(0, 1)) as Ship; + lasing = stage.Place(EvLonginus(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship; }); var (_, arena, _, failures) = Launch(scenario, Inputs(() => GalaxySeed)); Assert.True(failures.Count == 0, string.Join("; ", failures)); @@ -640,32 +613,32 @@ public sealed partial class RunStartTests } // 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 (a tick or two: the probe reads 0.783 s against a window of 0.75), and stay 0, with the moments back at exactly zero. + // Djinni jinks for four seconds, the input is cut, and evasion must read exactly 0 within the window plus a tick + // or two, and stay 0, with the moments back at exactly zero. [Fact] public void AShipThatStopsJinkingStopsEvading() { - var (_, _, _, gemini) = EvFleet(); + var (_, _, djinni) = 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}"); + EvSettle(djinni); + EvFly(djinni, EvJink.Lateral, 1f, 4f); + var jinking = Math.Max(FireControl.Evasion(djinni, float2(0, 1)), FireControl.Evasion(djinni, EvRight(djinni))); + Assert.True(jinking > 1f, $"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)); + djinni.Update(EvDt); + var nose = FireControl.Evasion(djinni, float2(0, 1)); + var side = FireControl.Evasion(djinni, EvRight(djinni)); 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 + 3 * EvDt, $"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); + Assert.Equal(0f, djinni.Manoeuvre.Moments.x); + Assert.Equal(0f, djinni.Manoeuvre.Moments.y); + Assert.Equal(0f, djinni.Manoeuvre.Moments.z); } // Finding limit-clamp-evasion: a velocity limiter's clamp is the ceiling, not a change of vector. A Longinus @@ -673,7 +646,7 @@ public sealed partial class RunStartTests [Fact] public void ASpeedAboveTheLimitIsNotEvasion() { - var (_, longinus, _, _) = EvFleet(); + var (_, longinus, _) = EvFleet(); EvSettle(longinus); longinus.Velocity = float2(0, 400); // Read every tick: the track forgets within a window, so only the ticks right after the clamp can see it. @@ -693,7 +666,7 @@ public sealed partial class RunStartTests [Fact] public void TheAggregatesReadLiveThrust() { - var (_, _, djinni, _) = EvFleet(); + 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); @@ -752,7 +725,7 @@ public sealed partial class RunStartTests [Fact] public void ReachMatchesABruteForceSearchOverTurnStrategies() { - var gemini = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f); + var omni = new ManoeuvreEnvelope(20, 20, 20, 20, 3, 2.5f); var longinus = new ManoeuvreEnvelope(50, 0, 23, 23, 2.3f, 2.3f); var lopsided = new ManoeuvreEnvelope(30, 10, 5, 15, 2, 1.4f); var forwardOnly = new ManoeuvreEnvelope(40, 0, 0, 0, 3, 1.5f); @@ -764,9 +737,9 @@ public sealed partial class RunStartTests var round = new[] { 135f, -135f, 180f, 100f, -100f }; var cases = new (ManoeuvreEnvelope envelope, float[] windows, float[] bearings)[] { - (gemini, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter), + (omni, new[] { .5f, .75f }, quarter), (longinus, new[] { .5f, .75f }, quarter), (lopsided, new[] { .5f, .75f }, quarter), (forwardOnly, new[] { .5f, .75f }, quarter), (slow, new[] { .5f, .75f }, quarter), - (gemini, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()), + (omni, new[] { .5f, .75f }, round), (slow, new[] { .5f, .75f }, round), (cannotTurn, new[] { .5f, .75f }, round.Concat(quarter).ToArray()), (lopsided, new[] { .5f }, new[] { 135f, -135f, 180f, -100f }), // Ships that can thrust one way only, over windows long enough for every face to finish its turn: the face that // must turn the long way round, past a right angle and past the wrap at 180 degrees, is the best one. @@ -866,7 +839,7 @@ public sealed partial class RunStartTests [Fact] public void AStepOfNoTimeHasNoAcceleration() { - var (_, longinus, _, _) = EvFleet(); + var (_, longinus, _) = EvFleet(); EvSettle(longinus); longinus.MovementDirection = float2(0, 1); for (var step = 0; step < 10; step++) longinus.Update(EvDt); @@ -913,7 +886,7 @@ public sealed partial class RunStartTests [Fact] public void EvasionIsReadFromTheHeadingNotTheVelocity() { - var (_, longinus, _, _) = EvFleet(); + var (_, longinus, _) = EvFleet(); var window = _items.GameplaySettings.SolutionWindow; EvSettle(longinus); longinus.Update(EvDt); @@ -938,62 +911,20 @@ public sealed partial class RunStartTests [Fact] public void TheShipsTrackObservesAtTheSolutionWindow() { - var gemini = EvFleet().gemini; + var djinni = EvFleet().djinni; var window = _items.GameplaySettings.SolutionWindow; - EvSettle(gemini); - var reference = gemini.Manoeuvre; + EvSettle(djinni); + var reference = djinni.Manoeuvre; for (var step = 0; step < 120; step++) { - gemini.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0); - gemini.Update(EvDt); - reference.Observe(gemini.Acceleration, EvDt, window); + djinni.MovementDirection = float2(step / 30 % 2 == 0 ? 1 : -1, 0); + djinni.Update(EvDt); + reference.Observe(djinni.Acceleration, EvDt, window); } Assert.True(reference.Moments.x > 1f, $"fixture: the jink is observed ({reference.Moments.x})"); - Assert.InRange(gemini.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f); - Assert.InRange(gemini.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .001f); - Assert.InRange(gemini.Manoeuvre.Trend.x, reference.Trend.x - .001f * Math.Abs(reference.Trend.x) - .001f, reference.Trend.x + .001f * Math.Abs(reference.Trend.x) + .001f); - } - - // Finding envelope-inputs-unpinned: a drive that is offline reports nothing, and a working one reports less along the - // way the ship is already outrunning the rotor: half the rotor's speed forward leaves half the push forward and the - // whole push back, and the same across the ship. - [Fact] - public void TheDrivesReportReadsItsStateAndTheShipsSpeed() - { - var gemini = EvFleet().gemini; - EvSettle(gemini); - gemini.Update(EvDt); - var drive = gemini.GetBehaviors().Single(); - var rest = drive.Manoeuvre(); - Assert.True(rest.Forward > 1f && rest.Reverse > 1f && rest.Left > 1f && rest.Right > 1f && rest.Clockwise > .1f, "fixture: a settled drive pushes every way"); - var rotor = max(drive.Rpm * drive.DriveData.RotorDiameter / 100, 1); - var forward = normalize(gemini.Direction); - var right = EvRight(gemini); - - gemini.Velocity = forward * (.5f * rotor.x); - var ahead = drive.Manoeuvre(); - Assert.InRange(ahead.Forward / rest.Forward, .49f, .51f); - Assert.InRange(ahead.Reverse / rest.Reverse, .99f, 1.01f); - gemini.Velocity = -forward * (.5f * rotor.x); - var astern = drive.Manoeuvre(); - Assert.InRange(astern.Reverse / rest.Reverse, .49f, .51f); - Assert.InRange(astern.Forward / rest.Forward, .99f, 1.01f); - gemini.Velocity = right * (.5f * rotor.y); - var starboard = drive.Manoeuvre(); - Assert.InRange(starboard.Right / rest.Right, .49f, .51f); - Assert.InRange(starboard.Left / rest.Left, .99f, 1.01f); - gemini.Velocity = -right * (.5f * rotor.y); - var port = drive.Manoeuvre(); - Assert.InRange(port.Left / rest.Left, .49f, .51f); - Assert.InRange(port.Right / rest.Right, .99f, 1.01f); - - gemini.Velocity = float2(0, 0); - drive.Item.EquippableItem.Durability = 0f; - gemini.Update(EvDt); - gemini.Update(EvDt); - Assert.False(drive.Item.Active.Value, "fixture: the drive is offline"); - var offline = drive.Manoeuvre(); - Assert.Equal(0f, offline.Forward + offline.Reverse + offline.Left + offline.Right + offline.Clockwise + offline.CounterClockwise); + Assert.InRange(djinni.Manoeuvre.Moments.x, reference.Moments.x * .999f, reference.Moments.x * 1.001f); + Assert.InRange(djinni.Manoeuvre.Moments.z, reference.Moments.z * .999f - .001f, reference.Moments.z * 1.001f + .001f); + Assert.InRange(djinni.Manoeuvre.Trend.x, reference.Trend.x - .001f * Math.Abs(reference.Trend.x) - .001f, reference.Trend.x + .001f * Math.Abs(reference.Trend.x) + .001f); } // The pure track forgets: after jinking, a track fed a constant acceleration (none at all, or a steady burn) reads @@ -1034,10 +965,9 @@ public sealed partial class RunStartTests [Fact] public void EveryAggregateAndTheCompensationReadLiveThrust() { - var (_, _, djinni, _) = EvFleet(); + var (_, _, djinni) = EvFleet(); EvSettle(djinni); djinni.Update(EvDt); - Assert.Empty(djinni.GetBehaviors()); var thrusters = djinni.GetBehaviors().Where(t => t.Item.Active.Value).ToList(); float Live(Thruster t) => t.Evaluate(((ThrusterData) t.Data).Thrust); float Mount(ItemRotation rotation, Func value) => thrusters.Where(t => t.Item.EquippableItem.Rotation == rotation).Sum(value); From 8cd71ed6da0ba32b4f64ea51255bc62e3ae4b5e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:39:59 +0200 Subject: [PATCH 16/29] WeaponData.Tracking and Weapon.Tracking: the gun's mount rate in degrees per second Key 33 (first free across WeaponData and its subclasses), default positive infinity: an unauthored gun has a mount that follows anything. The resolver's NaN fallback to Min yields +infinity for the degenerate lerp. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/Weapon.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs index 450490cfe..82febb067 100644 --- a/Assets/Scripts/ServerShared/Behaviors/Weapon.cs +++ b/Assets/Scripts/ServerShared/Behaviors/Weapon.cs @@ -66,6 +66,11 @@ public abstract class WeaponData : BehaviorData [Inspectable, JsonProperty("velocity"), Key(16)] public PerformanceStat Velocity = new PerformanceStat(); + + // How fast the mount follows a target sweeping the shooter's sky, in degrees per second, authored per gun (ruling + // weapon-tracking-authored). Unauthored is a mount that follows anything: FireControl.PMount reads +infinity as 1. + [Inspectable, JsonProperty("tracking"), Key(33), RuntimeInspectable] + public PerformanceStat Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity }; } public abstract class Weapon : Behavior, IActivatedBehavior @@ -87,6 +92,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior public float Visibility { get; protected set; } public float Spread { get; protected set; } public float Velocity { get; protected set; } + public float Tracking { get; protected set; } // Mining Cut 3 fix (Q12 A, "launchers cannot mine"): whether this weapon can mine a chunk. The one mining // predicate: chunk reach (Entity.VisibleChunksInReach) reads it, and so does anything that decides a weapon @@ -138,6 +144,7 @@ public abstract class Weapon : Behavior, IActivatedBehavior Visibility = Evaluate(_data.Visibility); Spread = Evaluate(_data.Spread); Velocity = Evaluate(_data.Velocity); + Tracking = Evaluate(_data.Tracking); } public override bool Execute(float dt) From b6487afd9520932f135adf418653ee0115a4fdf0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:40:00 +0200 Subject: [PATCH 17/29] FireControl: the mount factor, AngularVelocity, TrackingRate and PMount, in the price, the freeze, Inspect and the HUD PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) multiplies HitProbability after the pFire gate; Fire freezes it into PendingShot.PFire; Inspect and the debug HUD show omega, rate and the factor. TrackingRate is the one product of the gun's rate and the ship's gear (ruling tracking-per-gun). Tracking(Entity) in metres still alone forgives the changes of vector (ruling jink-per-ship). Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 1 + Assets/Scripts/ServerShared/FireControl.cs | 48 ++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index c8f0bb6e2..6cf994119 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1378,6 +1378,7 @@ public class ActionGameManager : MonoBehaviour $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + + $"mount {d.AngularVelocity:F1}/{d.TrackingRate:F1} x{d.PMount:F3}\n" + $"{outcomeLine}\n" + $"{pendingLine}\n" + lastLine; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 8e43e40eb..fd14860d0 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -193,6 +193,28 @@ public static class FireControl return system != null && system.Item.Active.Value ? system.Tracking : entity.ItemManager.GameplaySettings.UnaidedTracking; } + // Two trackings, two units. Tracking(Entity) above is the ship's targeting gear in METRES: how much changing of + // vector a shooter forgives (PDeviation's tolerance). TrackingRate is a RATE in degrees per second: how fast the + // gun's mount can swing, and it alone prices the angular term. The ship's metres never forgive the mount and the + // gun's rate never forgives the jink (ruling jink-per-ship). + // + // How fast the target sweeps across the shooter's sky, in degrees per second: the component of the relative + // velocity transverse to the line of sight over the range, floored at one metre so a target on top of the + // shooter does not read infinite. Planar, allocation-free, and the one place this angle is computed. + public static float AngularVelocity(float2 offset, float2 relativeVelocity) + { + var range = max(length(offset), 1f); + return degrees(abs(offset.x * relativeVelocity.y - offset.y * relativeVelocity.x) / (range * range)); + } + + public static float AngularVelocity(Entity source, Entity target) => + AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity); + + // The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the + // one product of the two. An unauthored gun is +infinity and so is its rate. + public static float TrackingRate(Weapon weapon, Entity source) => + weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking; + // Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to // aim, while the roll measured deviation against a separately-computed prediction -- two functions // claiming to answer the same question will eventually disagree, and an AI would then aim at one point and @@ -324,7 +346,7 @@ public static class FireControl var targetHull = source.ItemManager.GetData(target.Hull) as HullData; var (sil, pSpread, evasion) = Forecast(weapon, source, target, targetHull, Precision(source), range); - return pFire * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source)); + return pFire * PMount(AngularVelocity(source, target), TrackingRate(weapon, source)) * pSpread * sil.POnHull * PDeviation(evasion, Tracking(source)); } // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes @@ -366,6 +388,9 @@ public static class FireControl diagnostic.POnHull = sil.POnHull; diagnostic.Evasion = evasion; diagnostic.PEvasion = PDeviation(evasion, diagnostic.Tracking); + diagnostic.AngularVelocity = AngularVelocity(source, target); + diagnostic.TrackingRate = TrackingRate(weapon, source); + diagnostic.PMount = PMount(diagnostic.AngularVelocity, diagnostic.TrackingRate); diagnostic.PBase = HitProbability(weapon, source, target); return diagnostic; @@ -433,6 +458,17 @@ public static class FireControl public static float PDeviation(float deviation, float tolerance) => tolerance <= 0f ? (deviation <= 0f ? 1f : 0f) : saturate(1f - deviation / tolerance); + // The mount's factor: the share of a shot that survives a target sweeping across the sky at `angularVelocity` + // degrees per second against a gun whose mount follows `rate` degrees per second. Half at the rate, a sixteenth at + // twice it: 0.5^((omega/rate)^2). An infinite rate (an unauthored gun) gives exactly 1; a rate of zero follows + // nothing that moves. + public static float PMount(float angularVelocity, float rate) + { + if (rate <= 0f) return angularVelocity <= 0f ? 1f : 0f; + var ratio = angularVelocity / rate; + return pow(.5f, ratio * ratio); + } + // The commit price's deviation: what the target has already moved off the frozen forecast, plus what it can and // does move off the line of fire (Evasion). The second part applies at any flight time, so an instant weapon // (flight time 0, realized deviation 0) still feels a jinking target. @@ -641,7 +677,8 @@ public static class FireControl // Solve is the one range and the one burst decision Inspect and the weapons' refusal check read too. var solution = Solve(weapon, item, source, target); if (solution.Outcome == FireOutcome.Refused) return 0; - var pFire = solution.PFire; + // The mount factor is frozen with the shooter-side probability: nothing at commit time re-reads the sweep. + var pFire = solution.PFire * (solution.Engaged != null ? PMount(AngularVelocity(source, solution.Engaged), TrackingRate(weapon, source)) : 1f); var fireRange = solution.FireRange; var engaged = solution.Engaged; var travelDirection = solution.TravelDirection; @@ -758,7 +795,7 @@ public static class FireControl } } - // R3/R4: the one roll. p_base was frozen at fire; the only thing measured live is how far the target has + // R3/R4: the one roll. p_base was frozen at fire, PFire carrying the mount factor; the only thing measured live is how far the target has // actually strayed, by now, from where a straight-line projection of its fire-time velocity said it would // be -- forgiven by the targeting system's Tracking, likewise frozen at fire. // @@ -1947,6 +1984,11 @@ public struct FireControlDiagnostic // The target's evasion along the shot (metres) and the share of the shot that survives it at this shooter's Tracking. public float Evasion; public float PEvasion; + // The mount: how fast the target sweeps the shooter's sky (degrees per second), the rate this gun on this ship + // follows (degrees per second) and the share of the shot that survives it. + public float AngularVelocity; + public float TrackingRate; + public float PMount; public float PBase; // What a round from this weapon does (Solve): the forecast for a fused weapon, whose percentages above do // not describe it. BurstReach is read only when Outcome is Burst. From 286a610b17156c2173d745c4e661814311ee436c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:43:32 +0200 Subject: [PATCH 18/29] EvasionTrackingTests: the mount term's rules, with EvCross taking a velocity AngularVelocityIsTransverseOverRange, TheMountHitsHalfAtItsRate, TrackingRateIsGunTimesShip, LightAndHeavyGunsSplitOnAFastCloseCrosser, ABigGunHitsABigShipAtRange, TheJinkIsStillForgivenPerShip, AShotFreezesItsMount, AnUnauthoredWeaponHasNoMountLimit. Rates are set on the loaded designs in code. Co-Authored-By: Claude Sonnet 5.5 --- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 8 +- .../EvasionTrackingTests.cs | 240 ++++++++++++++++++ 2 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index a5f3fd427..b847f3f6c 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -503,14 +503,14 @@ public sealed partial class RunStartTests return new EvRange { Arena = arena, Gunner = gunner, Lasing = lasing, Target = target, Gun = gun, Laser = laser }; } - // The target crossing at 150 m/s, either coasting or after two seconds of jinking, at the same velocity, heading - // and place. - private static void EvCross(EvRange range, bool jinking) + // The target crossing at 150 m/s (or at the velocity given), either coasting or after two seconds of jinking, at the + // same velocity, heading and place. + private static void EvCross(EvRange range, bool jinking, float2? velocity = null) { var target = range.Target; EvSettle(target); if (jinking) EvFly(target, EvJink.Lateral, .5f, 2f); - target.Velocity = float2(150, 0); + target.Velocity = velocity ?? float2(150, 0); target.Direction = float2(0, 1); target.Position = float3(0, target.Position.y, 200); } diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs new file mode 100644 index 000000000..bcc75c208 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -0,0 +1,240 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +using System; +using System.Collections.Generic; +using System.Linq; +using CultMath; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; + +// The mount term (docs/aetheria-release-map.md, "Tracking per gun"): how fast a gun's mount can follow a target +// sweeping across the shooter's sky, one factor of every hit price. Rulings tracking-per-gun ("gun times ship"), +// weapon-tracking-authored (a rate is authored per gun, never derived from its footprint), jink-per-ship and +// evasion-angular-velocity. Reuses EvasionTermTests' fixtures: Longinus gun platforms and a generated Djinni. +public sealed partial class RunStartTests +{ + private static PerformanceStat EvRate(float rate) => new PerformanceStat { Min = rate, Max = rate }; + + // The gun's rate, set on its design the way an author sets it, flown two updates so the weapon reads it. + private static void EvSetRate(Weapon weapon, Ship shooter, float rate) + { + weapon.WeaponData.Tracking = EvRate(rate); + shooter.Update(EvDt); + shooter.Update(EvDt); + Assert.Equal(rate, weapon.Tracking); + } + + // The range fixture with the projectile gun at one rate and the laser at another, the target seen by both. + private EvRange EvMountRange(float gunRate, float laserRate) + { + var range = EvRangeLaunch(); + EvSetRate(range.Gun, range.Gunner, gunRate); + EvSetRate(range.Laser, range.Lasing, laserRate); + Assert.True(range.Gunner.VisibleEntities.Contains(range.Target) && range.Lasing.VisibleEntities.Contains(range.Target), "fixture: the shooters still see the target"); + return range; + } + + private static (Weapon weapon, Ship shooter)[] EvGuns(EvRange range) => new[] { (range.Gun, range.Gunner), (range.Laser, range.Lasing) }; + + [Fact] + public void AngularVelocityIsTransverseOverRange() + { + // 150 m/s across a thousand metres is 0.15 rad/s, whichever way across, whichever way the line of sight points. + var expected = degrees(.15f); + Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, 150)), 3); + Assert.Equal(expected, FireControl.AngularVelocity(float2(1000, 0), float2(0, -150)), 3); + Assert.Equal(expected, FireControl.AngularVelocity(float2(0, 1000), float2(150, 0)), 3); + Assert.Equal(expected, FireControl.AngularVelocity(float2(-1000, 0), float2(0, 150)), 3); + var angle = radians(37f); + float2 Turn(float2 v) => float2(v.x * cos(angle) - v.y * sin(angle), v.x * sin(angle) + v.y * cos(angle)); + Assert.Equal(expected, FireControl.AngularVelocity(Turn(float2(1000, 0)), Turn(float2(0, 150))), 3); + // Only the transverse part counts: radial velocity is no sweep at all, and a general case reads both terms. + Assert.Equal(0f, FireControl.AngularVelocity(float2(1000, 0), float2(150, 0)), 4); + Assert.Equal(degrees(17000f / 250000f), FireControl.AngularVelocity(float2(300, 400), float2(-20, 30)), 3); + // A target nearer than a metre is read at a metre: 1 m/s across half a metre is 0.5 rad/s, not 2. + Assert.Equal(degrees(.5f), FireControl.AngularVelocity(float2(.5f, 0), float2(0, 1)), 3); + + // The entity overload reads the relative velocity, planar: a common velocity is no sweep, and the shooter's own + // motion is subtracted from the target's. + var (_, source, target) = EvFleet(); + source.Position = float3(0, 0, 0); + target.Position = float3(1000, 0, 0); + source.Velocity = float2(30, 40); + target.Velocity = float2(30, 40); + Assert.Equal(0f, FireControl.AngularVelocity(source, target), 4); + source.Velocity = float2(0, 100); + target.Velocity = float2(0, 250); + Assert.Equal(expected, FireControl.AngularVelocity(source, target), 3); + } + + [Fact] + public void TheMountHitsHalfAtItsRate() + { + foreach (var rate in new[] { .5f, 3f, 40f }) + { + Assert.Equal(.5f, FireControl.PMount(rate, rate), 6); + Assert.Equal(1f, FireControl.PMount(0f, rate)); + Assert.Equal(1f / 16f, FireControl.PMount(2 * rate, rate), 6); + Assert.Equal(1f, FireControl.PMount(rate, float.PositiveInfinity)); + } + Assert.Equal(1f, FireControl.PMount(1e6f, float.PositiveInfinity)); + // A mount that follows nothing hits only what stands still. + Assert.Equal(0f, FireControl.PMount(1f, 0f)); + Assert.Equal(1f, FireControl.PMount(0f, 0f)); + Assert.Equal(0f, FireControl.PMount(1f, -2f)); + var previous = float.PositiveInfinity; + for (var omega = 0f; omega <= 5f; omega += .25f) + { + var p = FireControl.PMount(omega, 1.5f); + Assert.True(p < previous, $"PMount falls as the sweep rises: {p} after {previous} at {omega}"); + previous = p; + } + } + + // Ruling tracking-per-gun, "gun times ship": the rate is the gun's authored rate times the shooter's gear over the + // unaided baseline, and nothing else multiplies the two. + [Fact] + public void TrackingRateIsGunTimesShip() + { + var range = EvMountRange(40f, 3f); + var unaided = _items.GameplaySettings.UnaidedTracking; + foreach (var (weapon, shooter) in EvGuns(range)) + { + var gear = FireControl.Tracking(shooter); + Assert.True(gear > unaided * 1.2f, $"fixture: the shooter's targeting gear tracks better than unaided ({gear} against {unaided})"); + var expected = weapon.Tracking * gear / unaided; + Assert.InRange(FireControl.TrackingRate(weapon, shooter), expected * .999f, expected * 1.001f); + } + var with = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray(); + + // The gear off: both guns' rates fall by the same ratio, and the ratio between the guns does not move. + foreach (var (_, shooter) in EvGuns(range)) shooter.GetBehavior().Item.Active.Value = false; + var without = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray(); + Assert.Equal(40f, without[0], 3); + Assert.Equal(3f, without[1], 3); + Assert.Equal(with[0] / without[0], with[1] / without[1], 3); + Assert.True(with[0] / without[0] > 1.2f); + Assert.Equal(with[0] / with[1], without[0] / without[1], 3); + } + + // Ruling weapon-tracking-authored, in play: a fast crosser close in is beyond a heavy gun and within a light one. + [Fact] + public void LightAndHeavyGunsSplitOnAFastCloseCrosser() + { + var range = EvMountRange(40f, 3f); + EvCross(range, jinking: false, float2(100, 0)); + var unaided = _items.GameplaySettings.UnaidedTracking; + var omega = degrees(100f / 200f); + var mounts = new List(); + foreach (var (weapon, shooter) in EvGuns(range)) + { + var d = FireControl.Inspect(weapon, shooter, range.Target); + var rate = weapon.Tracking * FireControl.Tracking(shooter) / unaided; + Assert.InRange(d.AngularVelocity, omega * .999f, omega * 1.001f); + Assert.InRange(d.TrackingRate, rate * .999f, rate * 1.001f); + Assert.Equal((float) Math.Pow(.5, Math.Pow(omega / rate, 2)), d.PMount, 4); + var price = FireControl.HitProbability(weapon, shooter, range.Target); + var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion; + Assert.True(withoutMount > 0f, "fixture: the shot is priced without the mount"); + Assert.InRange(price, withoutMount * d.PMount * .999f, withoutMount * d.PMount * 1.001f); + Assert.Equal(price, d.PBase, 6); + mounts.Add(d.PMount); + } + Assert.True(mounts[0] >= .85f, $"the light gun follows a fast close crosser: {mounts[0]}"); + Assert.True(mounts[1] <= .01f, $"the heavy gun does not: {mounts[1]}"); + } + + // Ruling evasion-angular-velocity: size is not what dodges, the sweep is. A big ship at range sweeps slowly, and a + // big gun still follows it. + [Fact] + public void ABigGunHitsABigShipAtRange() + { + var range = EvMountRange(2f, 2f); + EvCross(range, jinking: false, float2(50, 0)); + range.Target.Position = float3(0, range.Target.Position.y, 1000); + var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target); + Assert.Equal(degrees(.05f), d.AngularVelocity, 2); + Assert.True(d.PMount >= .6f, $"a 2 degree per second gun against 50 m/s at 1000 m: {d.PMount} (omega {d.AngularVelocity}, rate {d.TrackingRate})"); + } + + // Ruling jink-per-ship: the changes-of-vector term is forgiven by the ship's gear alone, in metres, whatever the gun. + // The target flies straight along the line of sight after jinking (no sweep, PMount 1), so the evasion is all there is. + [Fact] + public void TheJinkIsStillForgivenPerShip() + { + var range = EvMountRange(40f, 3f); + EvCross(range, jinking: true, float2(0, -50)); + foreach (var (weapon, shooter) in EvGuns(range)) + { + var d = FireControl.Inspect(weapon, shooter, range.Target); + Assert.Equal(1f, d.PMount, 4); + Assert.True(d.Evasion > 0f, "fixture: the target jinks"); + Assert.Equal(FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter)), d.PEvasion, 5); + var id = FireControl.Fire(weapon, weapon.Item, shooter); + Assert.True(id > 0, "fixture: the weapon fires"); + var shot = range.Arena.PendingShots.Single(s => s.ShotId == id); + Assert.Equal(FireControl.Tracking(shooter), shot.Tracking); + } + } + + // The mount factor is frozen at fire inside PFire, and the commit price is the live price. + [Fact] + public void AShotFreezesItsMount() + { + var range = EvMountRange(40f, 3f); + EvCross(range, jinking: false, float2(100, 0)); + foreach (var (weapon, shooter) in EvGuns(range)) + { + var d = FireControl.Inspect(weapon, shooter, range.Target); + var live = FireControl.HitProbability(weapon, shooter, range.Target); + Assert.True(d.PMount < 1f && d.PMount > 0f && d.PFire > 0f, "fixture: the mount prices the shot"); + var id = FireControl.Fire(weapon, weapon.Item, shooter); + Assert.True(id > 0, "fixture: the weapon fires"); + var shot = range.Arena.PendingShots.Single(s => s.ShotId == id); + Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f); + var commit = FireControl.CommitProbability(shot, range.Arena.Time, out _); + Assert.InRange(commit, live * .999f, live * 1.001f); + + // The target turns to fly straight along the line of sight: the live mount factor is now 1, the frozen one is not. + range.Target.Velocity = float2(0, 100); + Assert.Equal(1f, FireControl.Inspect(weapon, shooter, range.Target).PMount, 4); + shot = range.Arena.PendingShots.Single(s => s.ShotId == id); + Assert.InRange(shot.PFire, d.PFire * d.PMount * .999f, d.PFire * d.PMount * 1.001f); + Assert.InRange(FireControl.CommitProbability(shot, range.Arena.Time, out _), commit * .999f, commit * 1.001f); + range.Target.Velocity = float2(100, 0); + } + + // A round fired at nothing carries no mount factor to freeze: it prices at nothing and nothing throws. + range.Gunner.SetTarget(TargetRef.None); + var untargeted = FireControl.Fire(range.Gun, range.Gun.Item, range.Gunner); + Assert.True(untargeted > 0, "fixture: the weapon fires at nothing"); + Assert.Equal(0f, range.Arena.PendingShots.Single(s => s.ShotId == untargeted).PFire); + } + + // A gun nobody has authored a rate for follows anything: +infinity, and PMount 1 exactly. + [Fact] + public void AnUnauthoredWeaponHasNoMountLimit() + { + var unauthored = new AutoWeaponData().Tracking; + Assert.True(float.IsPositiveInfinity(unauthored.Min) && float.IsPositiveInfinity(unauthored.Max), "a code-built weapon's rate is +infinity"); + + var range = EvMountRange(5f, 5f); + EvCross(range, jinking: false, float2(100, 0)); + var limited = FireControl.HitProbability(range.Gun, range.Gunner, range.Target); + range.Gun.WeaponData.Tracking = unauthored; + range.Gunner.Update(EvDt); + range.Gunner.Update(EvDt); + Assert.True(float.IsPositiveInfinity(range.Gun.Tracking), $"it evaluates to +infinity: {range.Gun.Tracking}"); + Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner))); + var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target); + Assert.Equal(1f, d.PMount); + var price = FireControl.HitProbability(range.Gun, range.Gunner, range.Target); + var withoutMount = d.PFire * d.PSpread * d.POnHull * d.PEvasion; + Assert.InRange(price, withoutMount * .999f, withoutMount * 1.001f); + Assert.True(price > limited * 2f, $"the mount limit priced the limited gun down: {limited} against {price}"); + } +} From 8e20617f5f927c40e23314f60022b742ca30b6bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:45:10 +0200 Subject: [PATCH 19/29] AetherDrive: restore the Execute signature the drop commit deleted with Manoeuvre e360ceeb removed one line too many (public override bool Execute), so the drop commit and its two successors do not build. The file is now exactly the base minus the 24 lines of Manoeuvre. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs index 81ee4471a..e8498ca7f 100644 --- a/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs +++ b/Assets/Scripts/ServerShared/Behaviors/AetherDrive.cs @@ -126,6 +126,7 @@ public class AetherDrive : Behavior, IPowerConsumer // Cut 5 (docs/stats-and-power-cut.md §1.3, PowerTiers.cs): Medium -- mobility, same as Thruster. public int DefaultPowerTier => PowerTiers.Medium; + public override bool Execute(float dt) { var rotorSpeed = Rpm * _data.RotorDiameter / 100; From e5232ad9d3dcad924e7d7f4a539a18920b71a2a6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:45:54 +0200 Subject: [PATCH 20/29] EvasionTrackingTests: take the targeting gear offline through its durability, as the gear tests do Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs index bcc75c208..1bc20d8f9 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -112,7 +112,13 @@ public sealed partial class RunStartTests var with = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray(); // The gear off: both guns' rates fall by the same ratio, and the ratio between the guns does not move. - foreach (var (_, shooter) in EvGuns(range)) shooter.GetBehavior().Item.Active.Value = false; + foreach (var (_, shooter) in EvGuns(range)) + { + shooter.GetBehavior().Item.EquippableItem.Durability = 0f; + shooter.Update(EvDt); + shooter.Update(EvDt); + Assert.Equal(unaided, FireControl.Tracking(shooter)); + } var without = EvGuns(range).Select(g => FireControl.TrackingRate(g.weapon, g.shooter)).ToArray(); Assert.Equal(40f, without[0], 3); Assert.Equal(3f, without[1], 3); From 2b7cf309d2104dd719330c80ac307b7fd9af36bf Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 16:55:54 +0200 Subject: [PATCH 21/29] EvasionTermTests: EvLonginus carries the Targeting Computer at the generated Longinus's cell; the stop-jinking probe reads at a half-period of 0.5 s Tool gear fits only interior hull cells and the Fire Control Array has no free one on a Longinus. The jink that stops at t=3 s has been quiet longer than the 0.75 s window by t=4 s, so the Djinni reads 0 there: the jink is the 0.5 s half-period that was already used in the speed-demon rows. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index b847f3f6c..a80df748b 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -23,7 +23,7 @@ public sealed partial class RunStartTests private const float EvDt = 1f / 60f; // The Duel's guns on a Longinus: the generated Longinus's thrusters (two Large Drive forward, two Talaria on the - // flanks) and power, the Duel's cockpit, guns, launchers and sensor, and the Fire Control Array as its targeting gear. + // flanks) and power, its Targeting Computer, and the Duel's cockpit, guns, launchers and sensor. private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("Longinus", ("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Core Power", int2(2, 4), ItemRotation.None), ("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Large Drive", int2(3, 0), ItemRotation.Reversed), @@ -31,7 +31,7 @@ public sealed partial class RunStartTests ("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None), (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise), ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 6), ItemRotation.None), - ("Fire Control Array", int2(1, 10), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); + ("Targeting Computer", int2(1, 7), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); // The catalog's two generated ships, far apart in the fixture arena, at the fixture seed. private (Zone arena, Ship longinus, Ship djinni) EvFleet() @@ -621,7 +621,7 @@ public sealed partial class RunStartTests var (_, _, djinni) = EvFleet(); var window = _items.GameplaySettings.SolutionWindow; EvSettle(djinni); - EvFly(djinni, EvJink.Lateral, 1f, 4f); + EvFly(djinni, EvJink.Lateral, .5f, 4f); var jinking = Math.Max(FireControl.Evasion(djinni, float2(0, 1)), FireControl.Evasion(djinni, EvRight(djinni))); Assert.True(jinking > 1f, $"the jink should evade, read {jinking}"); From 7d80af6dfec4da20addaf3350a43a7152d761dd0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:02:52 +0200 Subject: [PATCH 22/29] EvasionTrackingTests: both guns on the one shooter, at the origin at rest A ship's targeting gear carries its own item quality, so Tracking(shooter) differs between two ships fitted alike: the gun-times-ship comparison needs two designs on one ship. The gunner now carries FastBlast+- and ColdFire, and the mount fixture puts it back at the origin so the sweep is the geometry the test states. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 11 ++++++----- tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs | 12 ++++++++---- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index a80df748b..0e40644c9 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -24,12 +24,12 @@ public sealed partial class RunStartTests // The Duel's guns on a Longinus: the generated Longinus's thrusters (two Large Drive forward, two Talaria on the // flanks) and power, its Targeting Computer, and the Duel's cockpit, guns, launchers and sensor. - private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-") => stage.Fit("Longinus", + private static ScenarioFit EvLonginus(ScenarioStage stage, string gun = "FastBlast+-", string second = null) => stage.Fit("Longinus", ("Cockpit 2x2", int2(2, 6), ItemRotation.None), ("Core Power", int2(2, 4), ItemRotation.None), ("Large Drive", int2(1, 0), ItemRotation.Reversed), ("Large Drive", int2(3, 0), ItemRotation.Reversed), ("Talaria", int2(2, 14), ItemRotation.CounterClockwise), ("Talaria", int2(3, 14), ItemRotation.Clockwise), ("GT 3K", int2(0, 5), ItemRotation.None), ("GT 3K", int2(5, 5), ItemRotation.None), (gun, int2(1, 8), ItemRotation.None), - (gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise), + (second ?? gun, int2(4, 8), ItemRotation.None), ("Iapyx", int2(2, 2), ItemRotation.CounterClockwise), ("Iapyx", int2(3, 2), ItemRotation.Clockwise), ("not if i see you first", int2(3, 10), ItemRotation.None), ("PotaT+-", int2(1, 6), ItemRotation.None), ("Targeting Computer", int2(1, 7), ItemRotation.None), ("Store-All Plus", int2(2, 8), ItemRotation.None)); @@ -476,7 +476,7 @@ public sealed partial class RunStartTests { public Zone Arena; public Ship Gunner, Lasing, Target; - public Weapon Gun, Laser; + public Weapon Gun, Laser, GunnerLaser; } private EvRange EvRangeLaunch() @@ -485,7 +485,7 @@ public sealed partial class RunStartTests var scenario = new Scripted(false, stage => { stage.Player(stage.Bare("Djinni"), float2(-50000, -50000)); - gunner = stage.Place(EvLonginus(stage), float2(0, 0), facing: float2(0, 1)) as Ship; + gunner = stage.Place(EvLonginus(stage, "FastBlast+-", "ColdFire"), float2(0, 0), facing: float2(0, 1)) as Ship; target = stage.Place(stage.Generated("Djinni"), float2(0, 200), facing: float2(0, 1)) as Ship; lasing = stage.Place(EvLonginus(stage, "ColdFire"), float2(0, 400), facing: float2(0, -1)) as Ship; }); @@ -500,7 +500,8 @@ public sealed partial class RunStartTests Assert.True(gunner.VisibleEntities.Contains(target) && lasing.VisibleEntities.Contains(target), "fixture: the shooters see the target"); var gun = gunner.GetBehaviors().First(weapon => weapon is AutoWeapon && weapon.Velocity > 100f); var laser = lasing.GetBehaviors().First(weapon => weapon is AutoWeapon && weapon.Velocity <= .01f); - return new EvRange { Arena = arena, Gunner = gunner, Lasing = lasing, Target = target, Gun = gun, Laser = laser }; + var gunnerLaser = gunner.GetBehaviors().First(weapon => weapon is AutoWeapon && weapon.Velocity <= .01f); + return new EvRange { Arena = arena, Gunner = gunner, Lasing = lasing, Target = target, Gun = gun, Laser = laser, GunnerLaser = gunnerLaser }; } // The target crossing at 150 m/s (or at the velocity given), either coasting or after two seconds of jinking, at the diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs index 1bc20d8f9..f534d65cc 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -28,17 +28,21 @@ public sealed partial class RunStartTests Assert.Equal(rate, weapon.Tracking); } - // The range fixture with the projectile gun at one rate and the laser at another, the target seen by both. + // The range fixture with the shooter's projectile gun at one rate and its laser at another, the target seen. The + // shooter is put back at the origin at rest, so the geometry is exactly what the test says. private EvRange EvMountRange(float gunRate, float laserRate) { var range = EvRangeLaunch(); EvSetRate(range.Gun, range.Gunner, gunRate); - EvSetRate(range.Laser, range.Lasing, laserRate); - Assert.True(range.Gunner.VisibleEntities.Contains(range.Target) && range.Lasing.VisibleEntities.Contains(range.Target), "fixture: the shooters still see the target"); + EvSetRate(range.GunnerLaser, range.Gunner, laserRate); + range.Gunner.Position = float3(0, range.Gunner.Position.y, 0); + range.Gunner.Velocity = float2(0, 0); + Assert.True(range.Gunner.VisibleEntities.Contains(range.Target), "fixture: the shooter still sees the target"); return range; } - private static (Weapon weapon, Ship shooter)[] EvGuns(EvRange range) => new[] { (range.Gun, range.Gunner), (range.Laser, range.Lasing) }; + // The two guns of the one shooter: same gear, same ship, so only the gun's own rate differs. + private static (Weapon weapon, Ship shooter)[] EvGuns(EvRange range) => new[] { (range.Gun, range.Gunner), (range.GunnerLaser, range.Gunner) }; [Fact] public void AngularVelocityIsTransverseOverRange() From db19ecfeb70f2749c8baddceea4aec9348542be4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 17:43:21 +0200 Subject: [PATCH 23/29] EvasionTrackingTests: the entity overload's offset is checked with the shooter away from the origin Stryker survivor at FireControl.cs:211 (target.Position + source.Position) was invisible while the shooter sat at 0. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs index f534d65cc..5689510fd 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -70,6 +70,9 @@ public sealed partial class RunStartTests source.Velocity = float2(30, 40); target.Velocity = float2(30, 40); Assert.Equal(0f, FireControl.AngularVelocity(source, target), 4); + // Wherever the pair stands: the offset is target minus source. + source.Position = float3(500, 0, 300); + target.Position = float3(1500, 0, 300); source.Velocity = float2(0, 100); target.Velocity = float2(0, 250); Assert.Equal(expected, FireControl.AngularVelocity(source, target), 3); From 1d85e1d4da801e908e91c598ea6bd600e91a7794 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 18:52:47 +0200 Subject: [PATCH 24/29] AetherDb: evasion-catalog, the literal per-weapon tracking table and plight's 1x2 Shape One-shot, dry run unless apply, refuses a second apply. Deleted in its own commit after the catalog lands. Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/Program.cs | 111 +++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c65f4c3f2..09bafd232 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -33,13 +33,14 @@ public static class Program case "brownout-migrate": return BrownoutMigrate(args.Contains("apply")); case "roles-migrate": return RolesMigrate(args.Contains("apply")); case "firing-arc-migrate": return FiringArcMigrate(args.Contains("apply")); + case "evasion-catalog": return EvasionCatalog(args.Contains("apply")); case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], evasion-catalog [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); return 1; } } @@ -1085,6 +1086,114 @@ public static class Program return 0; } + + // Evasion r6 (aetheria-release:cut_spec:cut-evasion-term.r6): authors every weapon's mount rate, in degrees per + // second, by name, and gives plight the 1x2 Shape of Spectra (ruling plight-shape). The table is the operator's + // authored outcome (ruling weapon-tracking-authored): nothing here reads a footprint, a caliber or a hardpoint to + // choose a rate. Launchers and guided weapons never mount-track; they are authored +infinity. Dry run unless passed + // "apply". Refuses a weapon without a row, a row without a record, a duplicated name, a rate authored to something + // else, and an apply with nothing left to land (the second apply). Deleted in its own commit after apply lands: the + // catalog is then the table's only copy. + private static int EvasionCatalog(bool apply) + { + var db = AetherDb.Open(catalogWritable: apply); + const float infinity = float.PositiveInfinity; + var rates = new (string Name, float Rate)[] + { + ("ClearPath", 12f), ("Spectra", 10f), ("FastBlast+-", 9f), ("Earp", 8f), ("6k Shooter", 7f), + ("ColdFire", 6f), ("Autocannon", 4f), ("ChargeBlast+-", 3.5f), ("DeathCluster", 3f), ("ChargeBlast SG", 3f), + ("pretty pretty bang bang", 3f), ("plight", 2f), ("CShot RainbowLite Lazer", 1.5f), + ("GT 3K", infinity), ("pswarm", infinity), ("scorched void policy", infinity), ("LRMM72", infinity), + ("SRMM72", infinity), + }; + + var refusals = new List(); + foreach (var group in rates.GroupBy(r => r.Name, StringComparer.Ordinal).Where(g => g.Count() > 1)) + refusals.Add($"the table names \"{group.Key}\" twice"); + + var weapons = db.Cache.GetAll().ToArray(); + foreach (var row in rates) + if (weapons.Count(w => w.Name == row.Name) != 1) + refusals.Add($"table row \"{row.Name}\" matches {weapons.Count(w => w.Name == row.Name)} catalog weapons, not 1"); + foreach (var weapon in weapons) + { + if (rates.All(r => r.Name != weapon.Name)) refusals.Add($"catalog weapon \"{weapon.Name}\" has no row"); + if (!weapon.Behaviors.OfType().Any()) refusals.Add($"catalog weapon \"{weapon.Name}\" has no weapon behaviour"); + } + + var spectra = weapons.FirstOrDefault(w => w.Name == "Spectra"); + var plight = weapons.FirstOrDefault(w => w.Name == "plight"); + if (spectra != null && (spectra.Shape.Width != 1 || spectra.Shape.Height != 2 || !spectra.Shape.Cells[0, 0] || !spectra.Shape.Cells[0, 1])) + refusals.Add("Spectra's Shape is not one wide, two high, both cells occupied"); + if (refusals.Count > 0) + { + foreach (var refusal in refusals) Console.WriteLine($"REFUSED: {refusal}"); + return 1; + } + + Console.WriteLine($"{"weapon",-26} {"field",-10} {"was",-14} {"now",-14}"); + var changed = new List<(object Document, CultRecordKey Key)>(); + foreach (var (name, rate) in rates) + { + var weapon = weapons.Single(w => w.Name == name); + var touched = false; + foreach (var behavior in weapon.Behaviors.OfType()) + { + var was = behavior.Tracking; + var label = was.Min + ".." + was.Max; + if (was.Min == rate && was.Max == rate) + { + Console.WriteLine($"{name,-26} {"Tracking",-10} {label,-14} {label,-14} (already authored, left alone)"); + continue; + } + if (!float.IsPositiveInfinity(was.Min) || !float.IsPositiveInfinity(was.Max)) + { + Console.WriteLine($"REFUSED: \"{name}\" Tracking is authored {label}, not the table's {rate}; the catalog was edited since"); + return 1; + } + behavior.Tracking = new PerformanceStat { Min = rate, Max = rate }; + Console.WriteLine($"{name,-26} {"Tracking",-10} {label,-14} {rate + ".." + rate,-14}"); + touched = true; + } + if (touched) changed.Add((weapon, db.Cache.RefOf(weapon).Key)); + } + + var wasShape = $"{plight.Shape.Width}x{plight.Shape.Height}"; + if (plight.Shape.Width == spectra.Shape.Width && plight.Shape.Height == spectra.Shape.Height + && plight.Shape.Cells.Cast().SequenceEqual(spectra.Shape.Cells.Cast())) + Console.WriteLine($"{"plight",-26} {"Shape",-10} {wasShape,-14} {wasShape,-14} (already Spectra's, left alone)"); + else + { + var copy = new Shape(spectra.Shape.Width, spectra.Shape.Height); + for (var x = 0; x < copy.Width; x++) + for (var y = 0; y < copy.Height; y++) + copy.Cells[x, y] = spectra.Shape.Cells[x, y]; + plight.Shape = copy; + Console.WriteLine($"{"plight",-26} {"Shape",-10} {wasShape,-14} {plight.Shape.Width + "x" + plight.Shape.Height,-14}"); + if (!changed.Any(c => ReferenceEquals(c.Document, plight))) changed.Add((plight, db.Cache.RefOf(plight).Key)); + } + + Console.WriteLine($"\n{changed.Count} weapon records changed"); + if (!apply) + { + Console.WriteLine(changed.Count == 0 ? "Dry run. Nothing to land." : $"Dry run. Pass \"apply\" to land {changed.Count} changed records."); + return 0; + } + if (changed.Count == 0) + { + Console.WriteLine("REFUSED: nothing to land; the catalog already carries the table."); + return 1; + } + + foreach (var (document, _) in changed) CultRecordRefs.Validate(document); + db.Cache.Commit(batch => + { + foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); + }); + Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); + return 0; + } + // Fire control Cut 6a (docs/fire-control-cut.md, Cut 2's "Catalog:" bullet). Cut 2 shipped the behaviour and // never shipped the data: no targeting-system design exists, so tools/AetherDb loadout throws // InvalidLoadoutException ("No compatible targeting system found for entity!") for every armed hull. This From 3d77f2e038d788c32e71cd1d66380fde258bd0ef Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 18:53:07 +0200 Subject: [PATCH 25/29] Catalog: every weapon's Tracking and plight's 1x2 Shape, by evasion-catalog apply Rates are the authored table (ruling weapon-tracking-authored); launchers and guided weapons stay +infinity. plight takes Spectra's one-by-two Shape (ruling plight-shape). Co-Authored-By: Claude Sonnet 5.5 --- GameData/Aetheria.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GameData/Aetheria.cc b/GameData/Aetheria.cc index d10c6d732..96135d923 100644 --- a/GameData/Aetheria.cc +++ b/GameData/Aetheria.cc @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78fe2afebd5caf98f7e01cf3c1cb3c7b11aed7d8971907f645c4929925e68f65 -size 12703072 +oid sha256:63250cf21ae9abeec657f0603336bb11b4d50b66560903ef45a8cceb01a7ae25 +size 12703489 From 846f836aac2e7236bead2836c8c71b7910680bdd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 18:54:17 +0200 Subject: [PATCH 26/29] AetherDb: delete evasion-catalog now that the migrated catalog is the table's only copy Co-Authored-By: Claude Sonnet 5.5 --- tools/AetherDb/Program.cs | 111 +------------------------------------- 1 file changed, 1 insertion(+), 110 deletions(-) diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index 09bafd232..c65f4c3f2 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -33,14 +33,13 @@ public static class Program case "brownout-migrate": return BrownoutMigrate(args.Contains("apply")); case "roles-migrate": return RolesMigrate(args.Contains("apply")); case "firing-arc-migrate": return FiringArcMigrate(args.Contains("apply")); - case "evasion-catalog": return EvasionCatalog(args.Contains("apply")); case "targeting-catalog": return TargetingCatalog(args.Contains("apply")); case "targeting-catalog-6c": return TargetingCatalog6c(args.Contains("apply")); case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply")); case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray()); case "field-kinds": return FieldKindsCatalog(args.Contains("apply")); default: - Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], evasion-catalog [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); + Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear ]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose, field-kinds [apply]"); return 1; } } @@ -1086,114 +1085,6 @@ public static class Program return 0; } - - // Evasion r6 (aetheria-release:cut_spec:cut-evasion-term.r6): authors every weapon's mount rate, in degrees per - // second, by name, and gives plight the 1x2 Shape of Spectra (ruling plight-shape). The table is the operator's - // authored outcome (ruling weapon-tracking-authored): nothing here reads a footprint, a caliber or a hardpoint to - // choose a rate. Launchers and guided weapons never mount-track; they are authored +infinity. Dry run unless passed - // "apply". Refuses a weapon without a row, a row without a record, a duplicated name, a rate authored to something - // else, and an apply with nothing left to land (the second apply). Deleted in its own commit after apply lands: the - // catalog is then the table's only copy. - private static int EvasionCatalog(bool apply) - { - var db = AetherDb.Open(catalogWritable: apply); - const float infinity = float.PositiveInfinity; - var rates = new (string Name, float Rate)[] - { - ("ClearPath", 12f), ("Spectra", 10f), ("FastBlast+-", 9f), ("Earp", 8f), ("6k Shooter", 7f), - ("ColdFire", 6f), ("Autocannon", 4f), ("ChargeBlast+-", 3.5f), ("DeathCluster", 3f), ("ChargeBlast SG", 3f), - ("pretty pretty bang bang", 3f), ("plight", 2f), ("CShot RainbowLite Lazer", 1.5f), - ("GT 3K", infinity), ("pswarm", infinity), ("scorched void policy", infinity), ("LRMM72", infinity), - ("SRMM72", infinity), - }; - - var refusals = new List(); - foreach (var group in rates.GroupBy(r => r.Name, StringComparer.Ordinal).Where(g => g.Count() > 1)) - refusals.Add($"the table names \"{group.Key}\" twice"); - - var weapons = db.Cache.GetAll().ToArray(); - foreach (var row in rates) - if (weapons.Count(w => w.Name == row.Name) != 1) - refusals.Add($"table row \"{row.Name}\" matches {weapons.Count(w => w.Name == row.Name)} catalog weapons, not 1"); - foreach (var weapon in weapons) - { - if (rates.All(r => r.Name != weapon.Name)) refusals.Add($"catalog weapon \"{weapon.Name}\" has no row"); - if (!weapon.Behaviors.OfType().Any()) refusals.Add($"catalog weapon \"{weapon.Name}\" has no weapon behaviour"); - } - - var spectra = weapons.FirstOrDefault(w => w.Name == "Spectra"); - var plight = weapons.FirstOrDefault(w => w.Name == "plight"); - if (spectra != null && (spectra.Shape.Width != 1 || spectra.Shape.Height != 2 || !spectra.Shape.Cells[0, 0] || !spectra.Shape.Cells[0, 1])) - refusals.Add("Spectra's Shape is not one wide, two high, both cells occupied"); - if (refusals.Count > 0) - { - foreach (var refusal in refusals) Console.WriteLine($"REFUSED: {refusal}"); - return 1; - } - - Console.WriteLine($"{"weapon",-26} {"field",-10} {"was",-14} {"now",-14}"); - var changed = new List<(object Document, CultRecordKey Key)>(); - foreach (var (name, rate) in rates) - { - var weapon = weapons.Single(w => w.Name == name); - var touched = false; - foreach (var behavior in weapon.Behaviors.OfType()) - { - var was = behavior.Tracking; - var label = was.Min + ".." + was.Max; - if (was.Min == rate && was.Max == rate) - { - Console.WriteLine($"{name,-26} {"Tracking",-10} {label,-14} {label,-14} (already authored, left alone)"); - continue; - } - if (!float.IsPositiveInfinity(was.Min) || !float.IsPositiveInfinity(was.Max)) - { - Console.WriteLine($"REFUSED: \"{name}\" Tracking is authored {label}, not the table's {rate}; the catalog was edited since"); - return 1; - } - behavior.Tracking = new PerformanceStat { Min = rate, Max = rate }; - Console.WriteLine($"{name,-26} {"Tracking",-10} {label,-14} {rate + ".." + rate,-14}"); - touched = true; - } - if (touched) changed.Add((weapon, db.Cache.RefOf(weapon).Key)); - } - - var wasShape = $"{plight.Shape.Width}x{plight.Shape.Height}"; - if (plight.Shape.Width == spectra.Shape.Width && plight.Shape.Height == spectra.Shape.Height - && plight.Shape.Cells.Cast().SequenceEqual(spectra.Shape.Cells.Cast())) - Console.WriteLine($"{"plight",-26} {"Shape",-10} {wasShape,-14} {wasShape,-14} (already Spectra's, left alone)"); - else - { - var copy = new Shape(spectra.Shape.Width, spectra.Shape.Height); - for (var x = 0; x < copy.Width; x++) - for (var y = 0; y < copy.Height; y++) - copy.Cells[x, y] = spectra.Shape.Cells[x, y]; - plight.Shape = copy; - Console.WriteLine($"{"plight",-26} {"Shape",-10} {wasShape,-14} {plight.Shape.Width + "x" + plight.Shape.Height,-14}"); - if (!changed.Any(c => ReferenceEquals(c.Document, plight))) changed.Add((plight, db.Cache.RefOf(plight).Key)); - } - - Console.WriteLine($"\n{changed.Count} weapon records changed"); - if (!apply) - { - Console.WriteLine(changed.Count == 0 ? "Dry run. Nothing to land." : $"Dry run. Pass \"apply\" to land {changed.Count} changed records."); - return 0; - } - if (changed.Count == 0) - { - Console.WriteLine("REFUSED: nothing to land; the catalog already carries the table."); - return 1; - } - - foreach (var (document, _) in changed) CultRecordRefs.Validate(document); - db.Cache.Commit(batch => - { - foreach (var (document, key) in changed) batch.Upsert(document.GetType(), document, key); - }); - Console.WriteLine($"Landed {changed.Count} changed records in Aetheria.cc"); - return 0; - } - // Fire control Cut 6a (docs/fire-control-cut.md, Cut 2's "Catalog:" bullet). Cut 2 shipped the behaviour and // never shipped the data: no targeting-system design exists, so tools/AetherDb loadout throws // InvalidLoadoutException ("No compatible targeting system found for entity!") for every armed hull. This From cdaef1a098fdc73b320703b849fe8ca5f5563585 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 18:57:54 +0200 Subject: [PATCH 27/29] EvasionTrackingTests: EveryCatalogGunTracks, TheCatalogMeetsTheRuledOutcomes, PlightHasItsRuledShape Data checks over the migrated catalog; no value is restated and nothing derives a rate from Shape. Co-Authored-By: Claude Sonnet 5.5 --- .../EvasionTrackingTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs index 5689510fd..229a3306b 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -250,4 +250,74 @@ public sealed partial class RunStartTests Assert.InRange(price, withoutMount * .999f, withoutMount * 1.001f); Assert.True(price > limited * 2f, $"the mount limit priced the limited gun down: {limited} against {price}"); } + + // The shipped catalog's weapon behaviours: launchers and guided weapons never mount-track, every other gun is a mount. + private static bool EvMountless(WeaponData data) => data is LauncherData || data is GuidedWeaponData; + + private IEnumerable<(WeaponItemData item, WeaponData data)> EvCatalogGuns() => + _cache.GetAll().SelectMany(item => item.Behaviors.OfType().Select(data => (item, data))); + + // Data check, not a behaviour test: it reads the migrated catalog. Pins finiteness and the authored shape of the + // value (Min = Max > 0), never a value (ruling weapon-tracking-authored). + [Fact] + public void EveryCatalogGunTracks() + { + var guns = EvCatalogGuns().ToArray(); + Assert.NotEmpty(guns); + foreach (var (item, data) in guns) + { + var rate = data.Tracking; + if (EvMountless(data)) + Assert.True(float.IsPositiveInfinity(rate.Min) && float.IsPositiveInfinity(rate.Max), $"{item.Name} is a launcher or guided weapon and tracks without a mount limit"); + else + { + Assert.True(!float.IsInfinity(rate.Min) && !float.IsNaN(rate.Min), $"{item.Name} has a finite rate, not {rate.Min}"); + Assert.Equal(rate.Min, rate.Max); + Assert.True(rate.Min > 0f, $"{item.Name} has a positive rate, not {rate.Min}"); + } + } + } + + // Data check, not a behaviour test: the ruled outcomes (weapon-tracking-authored) read from the migrated catalog, + // on the Targeting Computer ship. The values are not restated here: only what the operator ruled about them. + [Fact] + public void TheCatalogMeetsTheRuledOutcomes() + { + var range = EvMountRange(40f, 3f); + var ratio = FireControl.Tracking(range.Gunner) / _items.GameplaySettings.UnaidedTracking; + Assert.True(ratio > 1.2f, $"fixture: the Targeting Computer ship tracks better than unaided ({ratio})"); + var crosser = FireControl.AngularVelocity(float2(0, 200), float2(100, 0)); + var djinni = FireControl.AngularVelocity(float2(0, 1000), float2(50, 0)); + var guns = EvCatalogGuns().Where(g => !EvMountless(g.data)).ToArray(); + Assert.NotEmpty(guns); + var big = 0; + foreach (var (item, data) in guns) + { + var rate = data.Tracking.Min * ratio; + var close = FireControl.PMount(crosser, rate); + Assert.True(close <= .5f, $"{item.Name} follows a 100 m/s crosser at 200 m only half the time at best: {close}"); + // The cell count picks which guns the ruling calls heavy; it never sets a rate. + if (item.Shape.Cells.Cast().Count(cell => cell) >= 4) + { + big++; + Assert.True(close <= .1f, $"{item.Name}, four cells or more, does not follow the close crosser: {close}"); + } + var far = FireControl.PMount(djinni, rate); + Assert.True(far >= .6f, $"{item.Name} follows a Djinni at 50 m/s at 1000 m: {far} (rate {rate})"); + } + Assert.True(big > 0, "fixture: the catalog has a four-cell gun"); + Assert.True(FireControl.PMount(crosser, 40f * ratio) >= .85f, "a 40 degree per second gun keeps the crosser"); + } + + // Data check, not a behaviour test: ruling plight-shape, plight is one wide, two high and fits a Longinus Energy hardpoint. + [Fact] + public void PlightHasItsRuledShape() + { + var plight = _cache.GetAll().Single(w => w.Name == "plight"); + Assert.Equal(1, plight.Shape.Width); + Assert.Equal(2, plight.Shape.Height); + Assert.True(plight.Shape.Cells[0, 0] && plight.Shape.Cells[0, 1], "both cells are occupied"); + var longinus = _cache.GetAll().Single(h => h.Name == "Longinus"); + Assert.Contains(longinus.Hardpoints, hardpoint => hardpoint.Type == HardpointType.Energy && hardpoint.Takes(plight)); + } } From a6484c25994df5c76cc13febf3f0d25c70a9c412 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 19:18:28 +0200 Subject: [PATCH 28/29] EvasionTermTests: the range fixture's guns follow anything, so the evasion tests price evasion and not the catalog's mount rates Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/EvasionTermTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 0e40644c9..13d302285 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -495,6 +495,10 @@ public sealed partial class RunStartTests { shooter.SetTarget(target); shooter.EntityInfoGathered[target] = 1f; + // These tests price evasion, not the mount: the catalog's authored rates would price a 150 m/s crosser at 200 m + // at nothing, so the guns follow anything here. EvasionTrackingTests sets the rates it means (EvMountRange). + foreach (var weapon in shooter.GetBehaviors()) + weapon.WeaponData.Tracking = new PerformanceStat { Min = float.PositiveInfinity, Max = float.PositiveInfinity }; } arena.Update(1f); Assert.True(gunner.VisibleEntities.Contains(target) && lasing.VisibleEntities.Contains(target), "fixture: the shooters see the target"); From 473a4bcccd7ad421026414a8c23d2a623a671364 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 19:59:29 +0200 Subject: [PATCH 29/29] Evasion fix batch C: drop the unruled catalog thresholds, pin the jink price and the torque trim membership, give an unlimited gun on zero gear one meaning - Delete ABigGunHitsABigShipAtRange and TheCatalogMeetsTheRuledOutcomes (rulings cshot-rainbowlite-rate, evasion-angular-velocity-r2). - TheJinkIsForgivenPerShipInThePrice: HitProbability of two guns of different rates carries PDeviation(Evasion, Tracking(shooter)). - TrackingRate: an unlimited gun is +infinity whatever the gear reads, not inf * 0 = NaN. - EveryAggregateAndTheCompensationReadLiveThrust: trim membership read from the thrusters' torques, not Ship's private list. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/ServerShared/FireControl.cs | 7 +- .../Aetheria.Shared.Tests/EvasionTermTests.cs | 13 ++- .../EvasionTrackingTests.cs | 91 ++++++++++--------- 3 files changed, 61 insertions(+), 50 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index fd14860d0..bff296829 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -211,9 +211,12 @@ public static class FireControl AngularVelocity((target.Position - source.Position).xz, target.Velocity - source.Velocity); // The gun's rate times the ship's gear over the unaided baseline (ruling tracking-per-gun, "gun times ship"): the - // one product of the two. An unauthored gun is +infinity and so is its rate. + // one product of the two. An unauthored gun is +infinity and so is its rate whatever the gear reads: a mount with no + // limit follows anything, and infinity times a gear that reads zero is not a number. public static float TrackingRate(Weapon weapon, Entity source) => - weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking; + float.IsPositiveInfinity(weapon.Tracking) + ? weapon.Tracking + : weapon.Tracking * Tracking(source) / source.ItemManager.GameplaySettings.UnaidedTracking; // Cut 3, the risk this map names explicitly: Combat.cs used to run its own first_order_intercept call to // aim, while the roll measured deviation against a separately-computed prediction -- two functions diff --git a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs index 13d302285..2b59bf41e 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTermTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTermTests.cs @@ -992,14 +992,19 @@ public sealed partial class RunStartTests // The compensation trims each torque thruster of a strafing side by the side's mean torque, over that thruster's // own torque at its live thrust. - foreach (var (side, field, direction, total) in new[] + foreach (var (side, direction, total) in new[] { - (ItemRotation.CounterClockwise, "RightStrafeTorqueThrusters", 1f, djinni.RightStrafeTotalTorque), - (ItemRotation.Clockwise, "LeftStrafeTorqueThrusters", -1f, djinni.LeftStrafeTotalTorque) + (ItemRotation.CounterClockwise, 1f, djinni.RightStrafeTotalTorque), + (ItemRotation.Clockwise, -1f, djinni.LeftStrafeTotalTorque) }) { - var members = (List) typeof(Ship).GetField(field, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(djinni); + // Which thrusters take the trim is the rule under test, so it is read from the thrusters' own torques and not from + // Ship's private list (finding compensation-membership-unpinned): the side's thrusters that turn the way the side's + // total torque turns. The fixture must have thrusters on both sides of that line, or the rule is not exercised. + var onSide = djinni.GetBehaviors().Where(t => t.Item.EquippableItem.Rotation == side).ToList(); + var members = onSide.Where(t => Math.Sign(t.Torque) == Math.Sign(total)).ToList(); Assert.NotEmpty(members); + Assert.True(members.Count < onSide.Count, $"fixture: the {side} side has a thruster that turns against its total torque, so membership is exercised"); djinni.MovementDirection = float2(direction, 0); djinni.LookDirection = float3(0, 0, 1); djinni.Update(EvDt); diff --git a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs index 229a3306b..08d886c57 100644 --- a/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs +++ b/tests/Aetheria.Shared.Tests/EvasionTrackingTests.cs @@ -161,19 +161,6 @@ public sealed partial class RunStartTests Assert.True(mounts[1] <= .01f, $"the heavy gun does not: {mounts[1]}"); } - // Ruling evasion-angular-velocity: size is not what dodges, the sweep is. A big ship at range sweeps slowly, and a - // big gun still follows it. - [Fact] - public void ABigGunHitsABigShipAtRange() - { - var range = EvMountRange(2f, 2f); - EvCross(range, jinking: false, float2(50, 0)); - range.Target.Position = float3(0, range.Target.Position.y, 1000); - var d = FireControl.Inspect(range.Gun, range.Gunner, range.Target); - Assert.Equal(degrees(.05f), d.AngularVelocity, 2); - Assert.True(d.PMount >= .6f, $"a 2 degree per second gun against 50 m/s at 1000 m: {d.PMount} (omega {d.AngularVelocity}, rate {d.TrackingRate})"); - } - // Ruling jink-per-ship: the changes-of-vector term is forgiven by the ship's gear alone, in metres, whatever the gun. // The target flies straight along the line of sight after jinking (no sweep, PMount 1), so the evasion is all there is. [Fact] @@ -194,6 +181,53 @@ public sealed partial class RunStartTests } } + // Finding jink-price-unpinned, ruling jink-per-ship at the price layer: with the target flying straight at the shooter (no + // sweep, PMount 1) the live price of two guns of different rates carries the same jink factor, PDeviation(Evasion, + // Tracking(shooter)), whatever their rates. The prices differ by the guns' own spread and fire factors; the jink does not. + [Fact] + public void TheJinkIsForgivenPerShipInThePrice() + { + var range = EvMountRange(40f, 3f); + EvCross(range, jinking: true, float2(0, -50)); + var guns = EvGuns(range); + Assert.NotEqual(guns[0].weapon.Tracking, guns[1].weapon.Tracking); + var factors = new List(); + foreach (var (weapon, shooter) in guns) + { + var d = FireControl.Inspect(weapon, shooter, range.Target); + Assert.Equal(1f, d.PMount, 4); + var forgiven = FireControl.PDeviation(d.Evasion, FireControl.Tracking(shooter)); + Assert.True(forgiven > .01f && forgiven < .99f, $"fixture: the jink prices between nothing and everything: {forgiven}"); + var shared = d.PFire * d.PMount * d.PSpread * d.POnHull; + Assert.True(shared > 0f, "fixture: the shared factors price the shot"); + var price = FireControl.HitProbability(weapon, shooter, range.Target); + Assert.InRange(price, shared * forgiven * .999f, shared * forgiven * 1.001f); + factors.Add(price / shared); + } + Assert.InRange(factors[0], factors[1] * .999f, factors[1] * 1.001f); + } + + // Finding zero-gear-nan: a gun with no authored limit follows anything, whatever the gear reads. Infinity times a gear + // that reads zero (active, not yet executed) is still infinity, so the price stays a number; a limited gun on that gear + // follows nothing that moves (rate 0). Both keep the ship's gear as the other factor of the product (tracking-per-gun). + [Fact] + public void AnUnlimitedGunFollowsAnythingOnGearThatReadsZero() + { + var range = EvMountRange(float.PositiveInfinity, 3f); + EvCross(range, jinking: false, float2(100, 0)); + var gear = range.Gunner.GetBehaviors().First(t => t.Item.Active.Value); + Assert.True(FireControl.Tracking(range.Gunner) > 0f, "fixture: the gear reads a positive Tracking before it is zeroed"); + ((TargetingSystemData) gear.Data).Tracking = EvRate(0f); + range.Gunner.Update(EvDt); + range.Gunner.Update(EvDt); + Assert.Equal(0f, FireControl.Tracking(range.Gunner)); + Assert.True(float.IsPositiveInfinity(FireControl.TrackingRate(range.Gun, range.Gunner)), "an unlimited gun's rate is +infinity on any gear"); + Assert.Equal(1f, FireControl.Inspect(range.Gun, range.Gunner, range.Target).PMount); + Assert.False(float.IsNaN(FireControl.HitProbability(range.Gun, range.Gunner, range.Target))); + Assert.Equal(0f, FireControl.TrackingRate(range.GunnerLaser, range.Gunner)); + Assert.Equal(0f, FireControl.Inspect(range.GunnerLaser, range.Gunner, range.Target).PMount); + } + // The mount factor is frozen at fire inside PFire, and the commit price is the live price. [Fact] public void AShotFreezesItsMount() @@ -278,37 +312,6 @@ public sealed partial class RunStartTests } } - // Data check, not a behaviour test: the ruled outcomes (weapon-tracking-authored) read from the migrated catalog, - // on the Targeting Computer ship. The values are not restated here: only what the operator ruled about them. - [Fact] - public void TheCatalogMeetsTheRuledOutcomes() - { - var range = EvMountRange(40f, 3f); - var ratio = FireControl.Tracking(range.Gunner) / _items.GameplaySettings.UnaidedTracking; - Assert.True(ratio > 1.2f, $"fixture: the Targeting Computer ship tracks better than unaided ({ratio})"); - var crosser = FireControl.AngularVelocity(float2(0, 200), float2(100, 0)); - var djinni = FireControl.AngularVelocity(float2(0, 1000), float2(50, 0)); - var guns = EvCatalogGuns().Where(g => !EvMountless(g.data)).ToArray(); - Assert.NotEmpty(guns); - var big = 0; - foreach (var (item, data) in guns) - { - var rate = data.Tracking.Min * ratio; - var close = FireControl.PMount(crosser, rate); - Assert.True(close <= .5f, $"{item.Name} follows a 100 m/s crosser at 200 m only half the time at best: {close}"); - // The cell count picks which guns the ruling calls heavy; it never sets a rate. - if (item.Shape.Cells.Cast().Count(cell => cell) >= 4) - { - big++; - Assert.True(close <= .1f, $"{item.Name}, four cells or more, does not follow the close crosser: {close}"); - } - var far = FireControl.PMount(djinni, rate); - Assert.True(far >= .6f, $"{item.Name} follows a Djinni at 50 m/s at 1000 m: {far} (rate {rate})"); - } - Assert.True(big > 0, "fixture: the catalog has a four-cell gun"); - Assert.True(FireControl.PMount(crosser, 40f * ratio) >= .85f, "a 40 degree per second gun keeps the crosser"); - } - // Data check, not a behaviour test: ruling plight-shape, plight is one wide, two high and fits a Longinus Energy hardpoint. [Fact] public void PlightHasItsRuledShape()