From da2957b81c86653e1c2ac22f12ddad5233eaf697 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 00:48:27 +0200 Subject: [PATCH] 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); + } +}