Cut 6d: the dart-throw kernel replaces the coin-flip landing rule

FireControl.Commit used to place a hit with a coin flip between exactly
the aimed item and a uniform-random hull cell -- no spatial term, so a
near-miss on a stern item was as likely to land in the bow as the stern.

One Gaussian kernel, in hull-schematic cell units, now decides both
halves of "where will this shot go": w(cell) = exp(-d^2/2*sigma^2) over
every occupied cell, sigma = 1/Precision. HitProbability folds the
kernel's on-hull mass share (pOnHull) into PBase at fire time (R10/Q6
unaffected); Commit draws the landing cell from the same weights. A shot
that passes the roll always lands on metal -- the off-hull chance is
priced into that one roll, never resolved as a second miss stage. The
uniform-random branch is deleted outright, not demoted.

Precision now feeds every shot's pOnHull, aimed or not, so its unaided
fallback (FireControl.Precision) reads GameplaySettings.UnaidedPrecision
instead of a bare 0 -- the same shape as the existing UnaidedAccuracy/
UnaidedTracking floors. pOnHull is saturated: the discrete sum over-
estimates the continuous kernel's integral once sigma drops below about
one cell, and a mass share cannot exceed the whole kernel.

Existing fixtures across FireAuthorityTests, FireControlCut4/5/6Tests and
TargetingSystemTests authored Precision as 0 (or relied on the old 0
fallback) purely as a don't-care value under the coin-flip rule, where it
only gated the aimed-item branch. Under the kernel it drives every shot's
sigma, so those fixtures are re-authored to keep pOnHull near its
ceiling and stay pinned on what they actually test.

mutation_tests_fire_control_cut3.sh's two anchors into HitProbability's
return line and the deleted coin-flip branch are updated to the
equivalent lines/mutations on the new code.
This commit is contained in:
Meta Vens 2026-09-19 21:37:39 +02:00
commit d528d88883
9 changed files with 168 additions and 33 deletions

View file

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

View file

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

View file

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

View file

@ -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<ItemManager, Ship, Ship> 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.

View file

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

View file

@ -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<ItemManager, Ship, Ship> beforeActivate = null)

View file

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

View file

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

View file

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