From 473a4bcccd7ad421026414a8c23d2a623a671364 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 7 Oct 2026 19:59:29 +0200 Subject: [PATCH] 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()