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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-07 19:59:29 +02:00
commit 473a4bcccd
3 changed files with 61 additions and 50 deletions

View file

@ -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

View file

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

View file

@ -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<float>();
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<TargetingSystem>().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<bool>().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()