diff --git a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs index 44658d332..96d9f3703 100644 --- a/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs +++ b/Assets/Scripts/ServerShared/Behaviors/TargetingSystem.cs @@ -29,8 +29,11 @@ public class TargetingSystemData : BehaviorData [Inspectable, JsonProperty("resolution"), Key(2), RuntimeInspectable] public PerformanceStat Resolution = new PerformanceStat(); - // The chance a hit lands on the item actually aimed at (Entity.TargetItem) rather than a random cell. - // Consumed by Cut 3's roll. + // Cut 6d (docs/fire-control-cut.md): grouping tightness, not a probability. FireControl.Sigma derives a + // Gaussian kernel's sigma as 1/Precision, in hull-schematic cell units -- a higher number groups tighter + // around whatever is aimed at (Entity.TargetItem's cells, or the hull's own centre of mass unaimed). + // Consumed by Cut 3's roll (folded into HitProbability as pOnHull) and by Commit's cell draw, both through + // the one kernel function -- never re-derived separately. [Inspectable, JsonProperty("precision"), Key(3), RuntimeInspectable] public PerformanceStat Precision = new PerformanceStat(); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index e9cc2829f..714a7c515 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -2,6 +2,7 @@ * 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; @@ -9,6 +10,7 @@ using static CultMath.math; using float2 = CultMath.float2; using float3 = CultMath.float3; using int2 = CultMath.int2; +using Random = CultMath.Random; // Cut 1 (docs/fire-control-cut.md): geometry -- whether a weapon bears on a point is decided here and // nowhere else -- no prefab, transform, ArticulationPoint or renderer may influence it (R6, R7). Mount @@ -102,10 +104,15 @@ public static class FireControl return system != null && system.Item.Active.Value ? system.Resolution : 1f; } + // Cut 6d (docs/fire-control-cut.md): falls back to GameplaySettings.UnaidedPrecision, the same shape + // Accuracy/Tracking already fall back to their own Unaided* floors. The old coin-flip rule could get away + // with a bare 0 here (0 meant "never draw the aimed-item branch," a legitimate value under that rule); the + // dart-throw kernel reads Precision as a grouping tightness, and 0 would ask Sigma for an infinite, division- + // guarded group -- not the authored "spraying at the silhouette" floor the spec asks for. public static float Precision(Entity entity) { var system = entity.GetBehavior(); - return system != null && system.Item.Active.Value ? system.Precision : 0f; + return system != null && system.Item.Active.Value ? system.Precision : entity.ItemManager.GameplaySettings.UnaidedPrecision; } // Cut 5, 5.1 (Soul finding 3): falls back to GameplaySettings.UnaidedTracking, exactly the shape Accuracy @@ -157,17 +164,28 @@ public static class FireControl (1f - settings.TargetDetectionInfoThreshold) / max(Resolution(source), 1e-3f); var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, demandCeiling, info)); + var targetHull = source.ItemManager.GetData(target.Hull) as HullData; + float pSpread; if (weapon.Spread > 0) { - var targetHull = source.ItemManager.GetData(target.Hull) as HullData; var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize; var angularRadius = degrees(atan(halfExtent / range)); pSpread = saturate(angularRadius / (weapon.Spread / 2f)); } else pSpread = 1f; - return Accuracy(source) * pSensor * pSpread; + // Cut 6d (docs/fire-control-cut.md): the dart-throw kernel's other half. pSpread above prices whether + // the weapon's own barrel-dispersion cone even reaches the ship's silhouette at this range (weapon + // hardware, whole-hull bounding size, range-dependent, aim-point-blind). pOnHull prices whether the + // targeting system's own aim scatter, centred on whatever is actually aimed at, lands on the hull's + // real schematic footprint rather than open grid around it (targeting hardware, per-cell shape, + // range-blind, aim-point-aware). Different inputs, different failure modes; see Cut(0's report for the + // double-charging call). Aim point is source.ResolvedTargetItem, exactly what Fire freezes below -- + // HitProbability and Fire read the same reveal-gated aim point, never two. + var pOnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, Precision(source)).POnHull; + + return Accuracy(source) * pSensor * pSpread * pOnHull; } // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the @@ -328,18 +346,16 @@ public static class FireControl if (hit) { + // Cut 6d: the same kernel HitProbability's pOnHull drew its mass from -- same aim point (the + // frozen Aimed, or the hull's own centre of mass), same sigma (the frozen Precision). A shot that + // passed the roll always lands on metal (R3: one roll decides damage; the off-hull share was + // already priced into that roll, not resolved here as a second stage), so the draw is unconditional + // and always returns an occupied cell -- no fallback branch, uniform or otherwise. var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData; - var aimedCells = shot.Aimed != null ? CellsOf(shot.Target, shot.Aimed) : null; - if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision) - { - aimed = shot.Aimed; - cell = aimedCells[random.NextInt(aimedCells.Length)]; - } - else - { - var coords = hullData.Shape.Coordinates; - cell = coords[random.NextInt(coords.Length)]; - } + var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, shot.Aimed); + var (cells, weights, totalWeight, _) = HullKernel(hullData, aimPoint, shot.Precision); + cell = WeightedPick(cells, weights, totalWeight, random); + if (aimedCells != null && Array.IndexOf(aimedCells, cell) >= 0) aimed = shot.Aimed; var shield = shot.Target.Shield; var shieldActive = shield != null && shield.Item.Active.Value; @@ -435,6 +451,74 @@ public static class FireControl } } + // Cut 6d (docs/fire-control-cut.md): the one kernel. HitProbability multiplies pOnHull; Commit draws the + // landing cell from the same per-cell weights. Nowhere else may compute a sigma or a weight -- this is the + // named risk from Cut 3 (two functions answering "where will this shot go" and eventually disagreeing), + // applied to placement instead of authority. + // + // Sigma is the frozen Precision's reciprocal, in hull-schematic cell units -- the same units + // Shape.CenterOfMass and GearOccupancy already use, so a sigma of 1 means "one cell's width of spread." + // Higher Precision (a tighter group) gives a smaller sigma; the 1e-3 floor only guards a stray zero or + // negative authored value; no authored Precision should ever reach it (GameplaySettings.UnaidedPrecision + // is the deliberately-bad floor Precision(Entity) itself falls back to, same shape as UnaidedAccuracy). + private static float Sigma(float precision) => 1f / max(precision, 1e-3f); + + // w(cell) = exp(-d^2 / 2*sigma^2) over every occupied hull cell, `d` the planar cell-space distance from + // the aim point, plus pOnHull = (sum of those weights) / (2*pi*sigma^2) -- the share of the full continuous + // kernel's mass (a 2D Gaussian integrates to 2*pi*sigma^2 over the infinite plane) that a discrete sum over + // occupied cells actually captures. The off-hull share -- outside the schematic's bounding box, or on an + // unoccupied cell inside it (a hole, a thin limb's missing neighbour) -- is exactly 1 - pOnHull, and it + // never gets a second roll (see Commit below): pOnHull already told HitProbability the price. + private static (int2[] Cells, float[] Weights, float TotalWeight, float POnHull) HullKernel(HullData hullData, float2 aimPoint, float precision) + { + var sigma = Sigma(precision); + var coords = hullData.Shape.Coordinates; + var weights = new float[coords.Length]; + var total = 0f; + for (var i = 0; i < coords.Length; i++) + { + var w = exp(-lengthsq((float2) coords[i] - aimPoint) / (2f * sigma * sigma)); + weights[i] = w; + total += w; + } + // saturate: the discrete sum only approximates the continuous kernel's integral, and undershoots badly + // once sigma drops below about one cell (an extremely tight, well-authored Precision) -- the aim cell + // alone can then carry a weight whose share of the (now tiny) 2*pi*sigma^2 denominator exceeds 1. A + // share of a kernel's mass cannot exceed the whole kernel; clamped here so every other reader of + // pOnHull (HitProbability's product of factors, this cut's own tests) can keep treating it as a + // probability rather than re-deriving the same guard at every call site. + var pOnHull = saturate(total / (2f * PI * sigma * sigma)); + return (coords, weights, total, pOnHull); + } + + // The aim point the kernel is centred on (R10/Q6: the same frozen Aimed both HitProbability and Fire read) + // -- the aimed item's own cell centroid when it currently occupies cells on the target, else the hull's + // own centre of mass. One path; the uniform-random branch this replaces is deleted, not demoted (Cut 6d). + private static (float2 AimPoint, int2[] AimedCells) ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed) + { + var aimedCells = aimed != null ? CellsOf(target, aimed) : null; + if (aimedCells != null && aimedCells.Length > 0) + return (aimedCells.Aggregate(float2.zero, (total, c) => total + (float2) c) / aimedCells.Length, aimedCells); + return (hullData.Shape.CenterOfMass, null); + } + + // A single weighted draw over the kernel's own occupied-cell weights -- the only way Commit picks a cell + // once a shot has already passed the roll (R3: one roll decides damage). totalWeight is passed in rather + // than resummed so this reads the identical mass HullKernel just computed. The trailing return guards only + // floating-point rounding at the very end of the accumulation; it can never fall through for a shot that + // reaches here, because the roll already established this hull has nonzero on-hull mass. + private static int2 WeightedPick(int2[] cells, float[] weights, float totalWeight, Random random) + { + var roll = random.NextFloat() * totalWeight; + var accumulated = 0f; + for (var i = 0; i < cells.Length; i++) + { + accumulated += weights[i]; + if (roll < accumulated) return cells[i]; + } + return cells[cells.Length - 1]; + } + // The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source // of truth for where an equipped item's footprint lands, the same table Entity.DamageSchematic reads. private static int2[] CellsOf(Entity target, EquippedItem item) diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 52949f04d..ffd369b01 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -227,6 +227,18 @@ public class GameplaySettings // fire-time-projection deviation forgiveness is the first guess and the operator's knob, the same shape // as UnaidedAccuracy: authored deliberately bad, not authored broken. public float UnaidedTracking = 10f; + // Cut 6d (docs/fire-control-cut.md): the unaided fallback for Precision, alongside UnaidedAccuracy and + // UnaidedTracking above -- FireControl.Precision falls back to this the same way it already falls back to + // UnaidedAccuracy/UnaidedTracking. Precision is now a grouping tightness (FireControl.Sigma = 1/Precision, + // in hull-schematic cell units), not the old coin-flip probability, so the unaided floor has to be + // authored on that scale. .3 -> sigma 3.33 cells: against the live catalog's own hulls (LonginusX 6x17, + // Zenith 12x12, Turret 8x8) that puts pOnHull at the hull's own centre of mass around .5-.84 -- broadly + // sprayed across the whole silhouette (sigma is a large fraction of the hull's own width) while still + // landing on the ship more often than not. A tighter floor (e.g. .1, sigma 10) was tried first and + // rejected: it drove pOnHull for the same hulls down to .08-.18, reading as "can't hit the broad side of a + // barn" rather than "sprays the silhouette." First guess, the same deliberately-bad shape as its two + // siblings; the operator rules the real figure in play. + public float UnaidedPrecision = .3f; public float AgentRangeExponent = .25f; public float AgentForwardLerp = .5f; public float AgentMaxForwardDistance = 50; diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index 3f65f24d8..b313f8541 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -44,6 +44,11 @@ public sealed class FireAuthorityTests : IDisposable CommitHorizon = .5f, SchematicCellSize = 1f, UnaidedAccuracy = .05f, + // Cut 6d (docs/fire-control-cut.md): high enough that pOnHull saturates to 1 at this fixture's 5x5 + // hull's own centre of mass -- these tests pin the Accuracy cap and hit/miss timing, not the dart- + // throw kernel, so the unaided floor here is authored to stay out of their way, the same reason + // Spread is authored 0 in most of these fixtures to keep pSpread pinned at 1. + UnaidedPrecision = 2f, AgentMinHitProbability = 0f }; @@ -67,7 +72,13 @@ public sealed class FireAuthorityTests : IDisposable GameplaySettings settings, float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, float spread = 0, float damageSpread = 0, float penetration = 0, - float accuracy = 1, float resolution = 1, float precision = 0, float tracking = 1000, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds HitProbability's pOnHull for every shot, aimed + // or not (the dart-throw kernel's sigma), where it used to matter only for the coin-flip aimed-item + // branch this cut deletes. The old default of 0 -- harmless when Precision only gated that branch -- + // would now flatten pOnHull to nearly nothing against this fixture's 5x5 hull (sigma = 1/Precision + // balloons), starving every test in this file that doesn't care about aim placement. 1 keeps pOnHull + // close to its ceiling for an unaimed shot at this hull's centre of mass without pinning it exactly. + float accuracy = 1, float resolution = 1, float precision = 1, float tracking = 1000, float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, Action beforeActivate = null) { @@ -430,13 +441,15 @@ public sealed class FireAuthorityTests : IDisposable Assert.True(dealt < 100f); // nowhere near the inflated 99999 value -- the original 10 landed } - // R5's payoff: with Precision 1 and p 1, only the aimed item's durability falls. - // Mutation: the roll ignores Aimed and always picks a uniform random hull cell. + // R5's payoff: with Precision authored extremely tight (Cut 6d: a sigma a tiny fraction of one cell) and + // p 1, only the aimed item's durability falls -- the dart-throw kernel still converges on a deterministic + // single-cell pick when the group is that tight, so this stays a same-cell-every-time assertion rather + // than a statistical one. Mutation: the draw ignores Aimed and always picks a uniform random hull cell. [Fact] public void AimedHitLandsOnSelectedItem() { EquippableItem aimedGear = null; - var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1, armor: 0, + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1000, armor: 0, beforeActivate: (items, shooter, target) => { // A second, distinct interior item on the target to aim at, equipped before Activate() (Entity. diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index cc30e415c..efb66b5f2 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -99,9 +99,12 @@ public sealed class FireControlCut4Tests : IDisposable { Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds pOnHull (the dart-throw kernel's sigma) + // for every shot, aimed or not -- 0 would balloon sigma and starve this fixture's rolls, which + // don't care about aim placement. See FireAuthorityTests.Build's own comment on this same change. Behaviors = { new TargetingSystemData { - Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(0), Tracking = Constant(1000) + Accuracy = Constant(1), Resolution = Constant(1), Precision = Constant(2), Tracking = Constant(1000) } } }); // Powers the beam: with no Reactor, PowerBus grants nothing and ConstantWeapon.Execute safes itself diff --git a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs index c294b59fe..48dccca21 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut5Tests.cs @@ -47,6 +47,9 @@ public sealed class FireControlCut5Tests : IDisposable SchematicCellSize = 1f, UnaidedAccuracy = .05f, UnaidedTracking = 10f, + // Cut 6d (docs/fire-control-cut.md): high enough that pOnHull saturates to 1 at this fixture's 5x5 + // hull's own centre of mass -- see FireAuthorityTests.TestSettings' own comment on this same value. + UnaidedPrecision = 2f, AgentMinHitProbability = 0f, BeamResolveInterval = .1f }; @@ -77,7 +80,10 @@ public sealed class FireControlCut5Tests : IDisposable GameplaySettings settings, float damage = 10, float range = 1000, float minRange = 0, float velocity = 0, float spread = 0, float damageSpread = 0, float penetration = 0, - float accuracy = 1, float resolution = 1, float precision = 0, float tracking = 1000, + // Cut 6d (docs/fire-control-cut.md): Precision now feeds every shot's pOnHull (the dart-throw kernel's + // sigma), aimed or not -- see FireAuthorityTests.Build's own comment on this same default change. 0 + // would balloon sigma and starve this fixture's hit tests, which don't care about aim placement. + float accuracy = 1, float resolution = 1, float precision = 1, float tracking = 1000, float targetRange = 100, bool equipTargeting = true, float hullDurability = 1000, float armor = 0, bool equipShield = false, float shieldCapacity = 1000, Action beforeActivate = null) diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs index 1054c25a3..72421af4e 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6Tests.cs @@ -113,7 +113,10 @@ public sealed class FireControlCut6Tests : IDisposable MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, Behaviors = { new TargetingSystemData { - Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(0), Tracking = Constant(tracking) + // Cut 6d (docs/fire-control-cut.md): Precision now feeds pOnHull (the dart-throw kernel's + // sigma) for every shot, aimed or not -- 0 would balloon sigma and starve this fixture's hit + // tests, none of which care about aim placement. See FireAuthorityTests.Build's own comment. + Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(1), Tracking = Constant(tracking) } } }); cache.FlushAsync().Wait(); diff --git a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs index d3a436340..478bc93c2 100644 --- a/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs +++ b/tests/Aetheria.Shared.Tests/TargetingSystemTests.cs @@ -264,7 +264,11 @@ public sealed class TargetingSystemTests : IDisposable // handle stays open per test -- BuildTarget is not called twice). var bareShip = BuildObserver(items, target.Zone); Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(bareShip), 4); - Assert.Equal(0f, FireControl.Precision(bareShip), 4); + // Cut 6d (docs/fire-control-cut.md): Precision now falls back to UnaidedPrecision, the same shape as + // Accuracy/Tracking above -- it used to fall back to a bare 0, which was fine when Precision only fed + // the old coin-flip's aimed-item branch and meant nothing to a shot with no aim point. Now it derives + // every shot's sigma, aimed or not, so the unaided floor has to be an authored number, not a bare zero. + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(bareShip), 4); Assert.Equal(1f, FireControl.Resolution(bareShip), 4); // Bring it online to prove the fixture can actually supply its own stats... @@ -278,7 +282,7 @@ public sealed class TargetingSystemTests : IDisposable equippedTargeting.UpdatePerformance(); Assert.False(equippedTargeting.Active.Value); Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4); - Assert.Equal(0f, FireControl.Precision(target), 4); + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4); Assert.Equal(1f, FireControl.Resolution(target), 4); // Repair it, then disable it directly instead: same fallback. @@ -288,7 +292,7 @@ public sealed class TargetingSystemTests : IDisposable equippedTargeting.Enabled.Value = false; Assert.False(equippedTargeting.Active.Value); Assert.Equal(settings.UnaidedAccuracy, FireControl.Accuracy(target), 4); - Assert.Equal(0f, FireControl.Precision(target), 4); + Assert.Equal(settings.UnaidedPrecision, FireControl.Precision(target), 4); } // The brownout ruling (docs/stats-and-power-cut.md) applies to Accuracy same as any other stat: a diff --git a/tests/mutation_tests_fire_control_cut3.sh b/tests/mutation_tests_fire_control_cut3.sh index b47255d12..679956741 100644 --- a/tests/mutation_tests_fire_control_cut3.sh +++ b/tests/mutation_tests_fire_control_cut3.sh @@ -205,11 +205,14 @@ check_mutation \ "red" # --- ProbabilityFollowsInputs, variant 1/3: drop pSensor -- probability stops depending on detection info. --- +# Cut 6d (docs/fire-control-cut.md) added a fourth factor, pOnHull, to this same return line -- the anchor +# below tracks the current line so this still targets the pSensor drop specifically, not a byte-exact copy of +# the pre-Cut-6d line, which no longer exists. check_mutation \ "ProbabilityFollowsInputs (1/3): HitProbability must scale with pSensor, not drop it" \ "$FIRE_CONTROL_CS" \ - 'return Accuracy(source) * pSensor * pSpread;' \ - 'return Accuracy(source) * pSpread;' \ + 'return Accuracy(source) * pSensor * pSpread * pOnHull;' \ + 'return Accuracy(source) * pSpread * pOnHull;' \ "FireAuthorityTests.ProbabilityFollowsInputs" \ "red" @@ -296,13 +299,17 @@ check_mutation \ "FireAuthorityTests.OutcomeIsSnapshotNotReread" \ "red" -# --- AimedHitLandsOnSelectedItem (R5's payoff): with Precision 1, a hit must land on the aimed item's own -# cells, not a uniform-random hull cell. --- +# --- AimedHitLandsOnSelectedItem (R5's payoff): with Precision authored extremely tight (Cut 6d: the test now +# passes precision: 1000, a sigma a tiny fraction of one cell), a hit must land on the aimed item's own cell, +# not the hull's centre of mass. Cut 6d deleted the coin-flip branch this mutation used to sabotage +# (`random.NextFloat() < shot.Precision` picking between the aimed cell and a uniform-random one); the +# equivalent sabotage under the dart-throw kernel is feeding Commit's aim-point resolver a null Aimed, so it +# falls back to the centre-of-mass path unconditionally -- the same "aim is ignored" defect, on the new code. --- check_mutation \ - "AimedHitLandsOnSelectedItem: the roll must respect Aimed, not always pick a uniform random hull cell" \ + "AimedHitLandsOnSelectedItem: the kernel's aim point must respect Aimed, not fall back to centre of mass" \ "$FIRE_CONTROL_CS" \ - 'if (aimedCells != null && aimedCells.Length > 0 && random.NextFloat() < shot.Precision)' \ - 'if (false)' \ + 'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, shot.Aimed);' \ + 'var (aimPoint, aimedCells) = ResolveAimPoint(shot.Target, hullData, null);' \ "FireAuthorityTests.AimedHitLandsOnSelectedItem" \ "red"