Cut 12.2: where a hit lands is priced and drawn from one silhouette
FireControl.Silhouette replaces HullKernel/WeightedPick/POnHull: the hull's lateral shadow at a bearing, merged into intervals, integrated exactly with CultMath.erf/erfinv instead of a discrete per-cell Gaussian sum. FireControl.Lane does exact slab traversal to pick the impact cell from a lateral offset; only its first element (the entry-nearest occupied cell) is used here, per the cut map -- the rest is 12.3's armour-first absorption march. Bearing timing: PendingShot.PBase is renamed PFire and now carries only the shooter-side factor (Accuracy x PSensor) behind the gates; TravelDirection, Spread and FireRange are frozen alongside it. The target's facing is read live at Commit through FireControl.CommitProbability (steps 1-5 of the spec's "Bearing timing"), not frozen at Fire, so a target that turns its armour into an incoming shot takes the hit there. ShotOutcome gains Bearing/Lateral, frozen at commit; Apply reads only those, never a live position or facing. Entity.ApplyHit takes an already-schematic bearing instead of a world direction, and the ToSchematic call 12.1 put inside it is gone. Superseded tests: PlacementAndProbabilityShareOneKernel, EveryHitLandsOnMetal and AimingAtTheSternHitsTheStern are replaced by FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette, HitsLandOnTheFacingEdge and AimingAtAnItemCentresTheScatterOnItsLane. FireAuthorityTests.AimedHitLandsOnSelectedItem and PenetrationMarchIsPlanar are updated for ApplyHit's new bearing parameter and Lane's entry-nearest-cell placement (a solid hull's Tool item is never itself the entry cell; reaching it now needs the same penetration march the new tests exercise). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc81867998
commit
7dbb3bcf7a
8 changed files with 1042 additions and 261 deletions
|
|
@ -3,6 +3,7 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CultMath;
|
||||
|
|
@ -147,7 +148,11 @@ public static class FireControl
|
|||
// actually possible, and they come from the same functions Inspect uses: one fire-control model, shared
|
||||
// at the level of its factors rather than by routing the hot path through a diagnostic struct.
|
||||
// HitProbabilityMatchesInspect pins that the two can never disagree.
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target)
|
||||
// Cut 12.2 (docs/fire-control-cut.md): the shooter-side share of the probability -- everything Fire
|
||||
// freezes (R10), gated exactly as HitProbability gates. Nothing about the target's facing or silhouette
|
||||
// enters here; that is Silhouette's and PSpread's job, priced fresh by HitProbability below and again,
|
||||
// live, by Commit at the commit tick. 0 the moment any gate closes.
|
||||
private static float PFire(Weapon weapon, Entity source, Entity target)
|
||||
{
|
||||
if (target == null) return 0f;
|
||||
if (!source.VisibleEntities.Contains(target)) return 0f;
|
||||
|
|
@ -159,12 +164,21 @@ public static class FireControl
|
|||
if (!InArc(weapon.Item, toTarget)) return 0f;
|
||||
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
|
||||
return Accuracy(source)
|
||||
* PSensor(settings, Resolution(source), info)
|
||||
* PSpread(weapon, targetHull, range, settings)
|
||||
* POnHull(source, target, targetHull, Precision(source));
|
||||
return Accuracy(source) * PSensor(settings, Resolution(source), info);
|
||||
}
|
||||
|
||||
public static float HitProbability(Weapon weapon, Entity source, Entity target)
|
||||
{
|
||||
var pFire = PFire(weapon, source, target);
|
||||
if (pFire <= 0f) return 0f;
|
||||
|
||||
var range = length(target.Position - source.Position);
|
||||
var targetHull = source.ItemManager.GetData(target.Hull) as HullData;
|
||||
var bearing = Bearing(target, TravelDirection(weapon, source, target));
|
||||
var sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, Precision(source));
|
||||
var settings = source.ItemManager.GameplaySettings;
|
||||
return pFire * PSpread(weapon.Spread, sil.Span, range, settings.SchematicCellSize) * sil.POnHull;
|
||||
}
|
||||
|
||||
// Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes
|
||||
|
|
@ -198,8 +212,12 @@ public static class FireControl
|
|||
diagnostic.Info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f;
|
||||
diagnostic.InfoDemandCeiling = InfoDemandCeiling(settings, diagnostic.Resolution);
|
||||
diagnostic.PSensor = PSensor(settings, diagnostic.Resolution, diagnostic.Info);
|
||||
diagnostic.PSpread = PSpread(weapon, targetHull, diagnostic.Range, settings);
|
||||
diagnostic.POnHull = POnHull(source, target, targetHull, diagnostic.Precision);
|
||||
diagnostic.PFire = diagnostic.Accuracy * diagnostic.PSensor;
|
||||
|
||||
var bearing = Bearing(target, TravelDirection(weapon, source, target));
|
||||
var sil = Silhouette(target, targetHull, source.ResolvedTargetItem, bearing, diagnostic.Precision);
|
||||
diagnostic.PSpread = PSpread(weapon.Spread, sil.Span, diagnostic.Range, settings.SchematicCellSize);
|
||||
diagnostic.POnHull = sil.POnHull;
|
||||
|
||||
diagnostic.PBase = HitProbability(weapon, source, target);
|
||||
return diagnostic;
|
||||
|
|
@ -216,22 +234,37 @@ public static class FireControl
|
|||
private static float PSensor(GameplaySettings settings, float resolution, float info) =>
|
||||
saturate(unlerp(settings.TargetDetectionInfoThreshold, InfoDemandCeiling(settings, resolution), info));
|
||||
|
||||
// Whether the weapon's own barrel-dispersion cone reaches the ship's silhouette at this range: weapon
|
||||
// hardware, whole-hull bounding size, range-dependent, aim-point-blind. 1 for any zero-spread weapon.
|
||||
private static float PSpread(Weapon weapon, HullData targetHull, float range, GameplaySettings settings)
|
||||
// Whether the weapon's own barrel-dispersion cone reaches the target's actual silhouette at this range:
|
||||
// weapon hardware, range-dependent, aim-point-blind. 1 for any zero-spread weapon. Cut 12.2: span comes
|
||||
// from the same Silhouette pOnHull reads, so the two factors can never disagree about the target's size
|
||||
// (the old bounding half-extent, max(Width, Height), is gone).
|
||||
private static float PSpread(float spread, float span, float range, float cellSize)
|
||||
{
|
||||
if (weapon.Spread <= 0) return 1f;
|
||||
var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize;
|
||||
if (spread <= 0) return 1f;
|
||||
var halfExtent = .5f * span * cellSize;
|
||||
var angularRadius = degrees(atan(halfExtent / range));
|
||||
return saturate(angularRadius / (weapon.Spread / 2f));
|
||||
return saturate(angularRadius / (spread / 2f));
|
||||
}
|
||||
|
||||
// Cut 6d: 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. The aim point is
|
||||
// source.ResolvedTargetItem, exactly what Fire freezes -- HitProbability, Inspect and Fire read the same
|
||||
// reveal-gated aim point, never two.
|
||||
private static float POnHull(Entity source, Entity target, HullData targetHull, float precision) =>
|
||||
HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem), precision).POnHull;
|
||||
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the shot's world-planar travel
|
||||
// direction -- PredictedIntercept minus the source's own position, planar (R7). Frozen into PendingShot
|
||||
// at Fire (R10); HitProbability and Inspect recompute it fresh, at the current source/target positions,
|
||||
// for their forecast. Below a length of 1e-6 (point-blank, source and intercept coincide) it falls back
|
||||
// to world +z, the same fallback Apply used before this cut (`:463`).
|
||||
public static float2 TravelDirection(Weapon weapon, Entity source, Entity target)
|
||||
{
|
||||
var toIntercept = (PredictedIntercept(weapon, source, target) - source.Position).xz;
|
||||
return lengthsq(toIntercept) < 1e-6f ? float2(0, 1) : normalize(toIntercept);
|
||||
}
|
||||
|
||||
// Cut 12.2: the only place a world direction meets a target's facing. One function, so nothing else may
|
||||
// compute a bearing (the authority map's forbidden-writer rule) -- Commit and the live forecast both read
|
||||
// this, never a second copy of the formula.
|
||||
private static float2 Bearing(Entity target, float2 travelDirection) => normalize(target.ToSchematic(travelDirection));
|
||||
|
||||
// Cut 12.2: the lateral axis a bearing implies, fixed once here. Silhouette and Lane both read it instead
|
||||
// of each carrying their own copy of the sign.
|
||||
private static float2 Lateral(float2 bearing) => float2(-bearing.y, bearing.x);
|
||||
|
||||
public static float DeviationProbability(PendingShot shot, float now, out float deviation)
|
||||
{
|
||||
|
|
@ -258,19 +291,22 @@ public static class FireControl
|
|||
var target = source.Target.Value;
|
||||
var now = zone.Time;
|
||||
|
||||
// R1's engage gate and probability, evaluated now and frozen: nothing at commit time re-reads a stat,
|
||||
// an info level or a range. Only live target *position* (deviation) is read again, at commit.
|
||||
var pBase = HitProbability(weapon, source, target);
|
||||
// R1's engage gate and the shooter-side probability, evaluated now and frozen: nothing at commit time
|
||||
// re-reads a stat, an info level or a range. Cut 12.2: PFire no longer carries the spread/hull share --
|
||||
// those are priced live at Commit, against the target's facing then, not now (Bearing timing, R10).
|
||||
var pFire = PFire(weapon, source, target);
|
||||
var travelDirection = target != null ? TravelDirection(weapon, source, target) : float2(0, 1);
|
||||
|
||||
var targetVelocity = float3.zero;
|
||||
var targetPosition = source.Position;
|
||||
var flightTime = 0f;
|
||||
var fireRange = 0f;
|
||||
if (target != null)
|
||||
{
|
||||
targetVelocity = float3(target.Velocity.x, 0, target.Velocity.y);
|
||||
targetPosition = target.Position;
|
||||
var range = length(targetPosition - source.Position);
|
||||
flightTime = weapon.Velocity > .01f ? range / weapon.Velocity : 0f;
|
||||
fireRange = length(targetPosition - source.Position);
|
||||
flightTime = weapon.Velocity > .01f ? fireRange / weapon.Velocity : 0f;
|
||||
}
|
||||
|
||||
var commitHorizon = source.ItemManager.GameplaySettings.CommitHorizon;
|
||||
|
|
@ -294,9 +330,12 @@ public static class FireControl
|
|||
Penetration = weapon.Penetration,
|
||||
DamageSpread = weapon.DamageSpread,
|
||||
DamageType = weapon.WeaponData.DamageType,
|
||||
PBase = pBase,
|
||||
PFire = pFire,
|
||||
Tracking = Tracking(source),
|
||||
Precision = Precision(source),
|
||||
TravelDirection = travelDirection,
|
||||
Spread = weapon.Spread,
|
||||
FireRange = fireRange,
|
||||
FireTime = now,
|
||||
FireTargetPosition = targetPosition,
|
||||
FireTargetVelocity = targetVelocity,
|
||||
|
|
@ -333,7 +372,7 @@ public static class FireControl
|
|||
// rewriting shot.Outcome: a committed outcome is immutable (R4) and stays a true record of what
|
||||
// the commit decided, while ShotResolved reports what actually happened -- nothing, because the
|
||||
// target is gone. Republishing the committed outcome here used to put a hit marker on a corpse.
|
||||
var miss = MakeOutcome(shot, false, false, false, int2.zero, now);
|
||||
var miss = MakeOutcome(shot, false, false, false, int2.zero, float2.zero, 0f, now);
|
||||
if (!shot.Committed) zone.ShotCommitted.OnNext(miss);
|
||||
zone.ShotResolved.OnNext(miss);
|
||||
shots.RemoveAt(i);
|
||||
|
|
@ -387,6 +426,27 @@ public static class FireControl
|
|||
return seed;
|
||||
}
|
||||
|
||||
// Cut 12.2 (docs/fire-control-cut.md, "Bearing timing"): steps 1-5. Everything the shooter decided is
|
||||
// frozen in `shot` (R10); everything the target is doing -- position (deviation) and now also facing
|
||||
// (bearing) -- is read live, here, at the commit tick. The Cut 7 guard extends to cover the new factors:
|
||||
// when PFire x pDeviation is already 0, no Silhouette is built. Shared by Commit's own roll and by the
|
||||
// debug HUD's forecast (TheHudEstimateIsTheCommitPrice) -- one function, one commit-time price.
|
||||
public static float CommitProbability(PendingShot shot, float now, out Silhouette sil)
|
||||
{
|
||||
sil = default;
|
||||
if (shot.Target == null) return 0f;
|
||||
|
||||
var pDeviation = DeviationProbability(shot, now, out _);
|
||||
var p = shot.PFire * pDeviation;
|
||||
if (p <= 0f) return 0f;
|
||||
|
||||
var bearing = Bearing(shot.Target, shot.TravelDirection);
|
||||
var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData;
|
||||
sil = Silhouette(shot.Target, hullData, shot.Aimed, bearing, shot.Precision);
|
||||
var settings = shot.Source.ItemManager.GameplaySettings;
|
||||
return p * PSpread(shot.Spread, sil.Span, shot.FireRange, settings.SchematicCellSize) * sil.POnHull;
|
||||
}
|
||||
|
||||
private static ShotOutcome Commit(Zone zone, PendingShot shot, float now)
|
||||
{
|
||||
// Cut 6b, 6.1 (Soul finding 6): a shot's dice belong to the shot, not to whatever else happened to
|
||||
|
|
@ -406,31 +466,37 @@ public static class FireControl
|
|||
// drawing once is the documented way to get a correlated first output, and a follow-up to give
|
||||
// CultMath.Random a mixed-seed entry point of its own is recorded for a separate CultLib cut.
|
||||
var random = new Random(MixSeed((zone.CombatSeed * 2654435761u) ^ (uint) shot.ShotId | 1u));
|
||||
var p = shot.PBase;
|
||||
if (p > 0f && shot.Target != null)
|
||||
{
|
||||
var pDeviation = DeviationProbability(shot, now, out _);
|
||||
// Cut 5, 5.1: Tracking is always positive now (Tracking() above never returns 0), so the branch
|
||||
// that used to turn a Tracking-less shooter's forgiveness into a hard <.01f wall is gone outright.
|
||||
p *= pDeviation;
|
||||
}
|
||||
var p = CommitProbability(shot, now, out var sil);
|
||||
|
||||
var hit = p > 0f && random.NextFloat() < p;
|
||||
var cell = int2.zero;
|
||||
var bearing = float2.zero;
|
||||
var lateral = 0f;
|
||||
var shielded = false;
|
||||
var shieldBroken = false;
|
||||
|
||||
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.
|
||||
// Cut 12.2: the same silhouette pOnHull drew its mass from -- same bearing (live, at this commit
|
||||
// tick), 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). The lateral draw picks where along the shadow the shot lands; Lane converts
|
||||
// that (bearing, lateral) pair into the actual cell -- only the first element (the impact cell,
|
||||
// nearest the shooter's side) is used here; the rest of the lane is 12.3's armour march.
|
||||
bearing = Bearing(shot.Target, shot.TravelDirection);
|
||||
lateral = LateralDraw(sil, random.NextFloat());
|
||||
|
||||
var hullData = shot.Source.ItemManager.GetData(shot.Target.Hull) as HullData;
|
||||
var aimPoint = ResolveAimPoint(shot.Target, hullData, shot.Aimed);
|
||||
var (cells, weights, totalWeight, _) = HullKernel(hullData, aimPoint, shot.Precision);
|
||||
cell = WeightedPick(cells, weights, totalWeight, random);
|
||||
var buffer = ArrayPool<LaneCell>.Shared.Rent(hullData.Shape.Coordinates.Length);
|
||||
try
|
||||
{
|
||||
var count = Lane(hullData, bearing, lateral, buffer);
|
||||
cell = count > 0 ? buffer[0].Cell : int2.zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<LaneCell>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
var shield = shot.Target.Shield;
|
||||
var shieldActive = shield != null && shield.Item.Active.Value;
|
||||
|
|
@ -438,7 +504,7 @@ public static class FireControl
|
|||
else if (shieldActive) shieldBroken = true;
|
||||
}
|
||||
|
||||
return MakeOutcome(shot, hit, shielded, shieldBroken, cell, now);
|
||||
return MakeOutcome(shot, hit, shielded, shieldBroken, cell, bearing, lateral, now);
|
||||
}
|
||||
|
||||
// R4: the commit is authoritative and immutable from here on -- this only performs what Commit already
|
||||
|
|
@ -458,14 +524,13 @@ public static class FireControl
|
|||
return;
|
||||
}
|
||||
|
||||
var hitDirection = float2(0, 1);
|
||||
var toTarget = (shot.Target.Position - shot.Source.Position).xz;
|
||||
if (lengthsq(toTarget) > 1e-6f) hitDirection = normalize(toTarget);
|
||||
|
||||
shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, hitDirection);
|
||||
// Cut 12.2 (R4): the committed geometry, frozen. Apply reads no position or facing of its own --
|
||||
// shot.Outcome.Bearing is already the schematic-frame bearing Commit computed at the commit tick, so
|
||||
// turning the target after commit changes nothing here (TurningAfterCommitChangesNothing).
|
||||
shot.Target.ApplyHit(shot.Source, shot.Outcome.Cell, shot.DamageSpread, shot.Penetration, shot.Damage, shot.Outcome.Bearing);
|
||||
}
|
||||
|
||||
private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float now)
|
||||
private static ShotOutcome MakeOutcome(PendingShot shot, bool hit, bool shielded, bool shieldBroken, int2 cell, float2 bearing, float lateral, float now)
|
||||
{
|
||||
return new ShotOutcome
|
||||
{
|
||||
|
|
@ -477,6 +542,8 @@ public static class FireControl
|
|||
Shielded = shielded,
|
||||
ShieldBroken = shieldBroken,
|
||||
Cell = cell,
|
||||
Bearing = bearing,
|
||||
Lateral = lateral,
|
||||
ArrivalIn = max(0f, shot.ArrivalTime - now),
|
||||
DamageType = shot.DamageType
|
||||
};
|
||||
|
|
@ -523,58 +590,19 @@ 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).
|
||||
//
|
||||
// 9.2 (docs/fire-control-cut.md, Soul C2): SigmaFloor guards a second, unrelated failure the 1e-3
|
||||
// floor above does nothing about. HullKernel approximates a continuous 2D Gaussian with a discrete
|
||||
// sum over occupied cells; that sum badly undershoots the continuous integral once sigma drops much
|
||||
// below one cell against an aim point that is not exactly on a cell centre -- true of every shipped
|
||||
// hull, none of whose centres of mass are integral. Past that point pOnHull collapsed toward zero as
|
||||
// Precision kept climbing, so a better targeting system made a shot un-fireable. Clamping sigma at
|
||||
// half a cell keeps the discrete sum a faithful share of the kernel over the whole Precision domain.
|
||||
// Cut 12.2 (docs/fire-control-cut.md): Sigma keeps its Cut 6d/9.2 rule unchanged -- the frozen Precision's
|
||||
// reciprocal, in hull-schematic cell units, floored at half a cell. The 9.2 floor's old rationale ("the
|
||||
// discrete sum undershoots the continuous integral") is retired: Silhouette below integrates the Gaussian
|
||||
// exactly over the shadow's intervals, so an exact 1D integral does not undershoot. The floor survives as
|
||||
// a design minimum on group tightness (F12-3, TheSigmaFloorHoldsAtHalfACell) -- a targeting system is never
|
||||
// authored tighter than half a schematic cell's width of spread, regardless of what the exact integral
|
||||
// would otherwise allow.
|
||||
private const float SigmaFloor = 0.5f;
|
||||
private static float Sigma(float precision) => max(1f / max(precision, 1e-3f), SigmaFloor);
|
||||
|
||||
// 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).
|
||||
// The aim point a silhouette 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 Cut 6d replaced stays deleted.
|
||||
private static float2 ResolveAimPoint(Entity target, HullData hullData, EquippedItem aimed)
|
||||
{
|
||||
var aimedCells = aimed != null ? CellsOf(target, aimed) : null;
|
||||
|
|
@ -583,26 +611,166 @@ public static class FireControl
|
|||
return hullData.Shape.CenterOfMass;
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 9.2 (docs/fire-control-cut.md, Soul C2): the trailing return used to carry a claim that it "can
|
||||
// never fall through," which was false on its own terms -- it falls through whenever totalWeight == 0,
|
||||
// returning a fixed corner cell, and was unreachable only as a side effect of pOnHull collapsing to
|
||||
// zero and gating PBase to zero first. Now that Sigma floors sigma so pOnHull no longer collapses,
|
||||
// that accidental gate is gone too; the return stays as an explicit floating-point-rounding guard, not
|
||||
// a documented invariant.
|
||||
private static int2 WeightedPick(int2[] cells, float[] weights, float totalWeight, Random random)
|
||||
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): the merged lateral shadow the
|
||||
// hull casts across the bearing, and the exact 1D Gaussian mass it carries -- the one function HitProbability's
|
||||
// forecast, Inspect's HUD factors and Commit's roll and placement all read. Nowhere else may compute a
|
||||
// sigma, a projection or a bearing. Allocates one interval array of length N (occupied-cell count, <=128 on
|
||||
// shipped hulls) plus a sort -- only on this, the gated-in, path (Cut 10's own gated-out guard is
|
||||
// unaffected: HitProbability and CommitProbability both bail on a zero shooter/deviation factor before
|
||||
// this is ever called).
|
||||
public static Silhouette Silhouette(Entity target, HullData hull, EquippedItem aimed, float2 bearing, float precision)
|
||||
{
|
||||
var roll = random.NextFloat() * totalWeight;
|
||||
var accumulated = 0f;
|
||||
for (var i = 0; i < cells.Length; i++)
|
||||
var ell = Lateral(bearing);
|
||||
var coords = hull.Shape.Coordinates;
|
||||
var h = (abs(ell.x) + abs(ell.y)) / 2f;
|
||||
|
||||
var intervals = new Interval[coords.Length];
|
||||
for (var i = 0; i < coords.Length; i++)
|
||||
{
|
||||
accumulated += weights[i];
|
||||
if (roll < accumulated) return cells[i];
|
||||
var centre = dot((float2) coords[i], ell);
|
||||
intervals[i] = new Interval { Lo = centre - h, Hi = centre + h };
|
||||
}
|
||||
return cells[cells.Length - 1];
|
||||
Array.Sort(intervals, (x, y) => x.Lo.CompareTo(y.Lo));
|
||||
|
||||
// Merge in place: sorted ascending by Lo, so an interval overlaps (or exactly abuts) the last kept
|
||||
// interval whenever its own Lo does not exceed that interval's Hi.
|
||||
var count = 0;
|
||||
for (var i = 0; i < intervals.Length; i++)
|
||||
{
|
||||
if (count > 0 && intervals[i].Lo <= intervals[count - 1].Hi)
|
||||
intervals[count - 1].Hi = max(intervals[count - 1].Hi, intervals[i].Hi);
|
||||
else
|
||||
intervals[count++] = intervals[i];
|
||||
}
|
||||
|
||||
var sigma = Sigma(precision);
|
||||
var aimPoint = ResolveAimPoint(target, hull, aimed);
|
||||
var a = dot(aimPoint, ell);
|
||||
|
||||
var pOnHull = 0f;
|
||||
for (var i = 0; i < count; i++)
|
||||
pOnHull += Phi((intervals[i].Hi - a) / sigma) - Phi((intervals[i].Lo - a) / sigma);
|
||||
|
||||
var span = count > 0 ? intervals[count - 1].Hi - intervals[0].Lo : 0f;
|
||||
|
||||
return new Silhouette
|
||||
{
|
||||
Intervals = intervals,
|
||||
Count = count,
|
||||
AimPoint = aimPoint,
|
||||
A = a,
|
||||
Sigma = sigma,
|
||||
Span = span,
|
||||
POnHull = saturate(pOnHull)
|
||||
};
|
||||
}
|
||||
|
||||
// Phi(z) = 1/2 (1 + erf(z / sqrt(2))): the standard normal CDF, CultMath's erf the one function underneath
|
||||
// every Gaussian-mass sum this cut computes.
|
||||
private static float Phi(float z) => .5f * (1f + erf(z * (1f / SQRT2)));
|
||||
|
||||
private const float SQRT2 = 1.4142135f;
|
||||
|
||||
// Cut 12.2: the lateral draw. One NextFloat u, taken after the hit roll -- walks the cumulative interval
|
||||
// mass to u * POnHull to select interval k, then inverts the Gaussian CDF inside it. `p` here is the
|
||||
// absolute CDF value at the drawn point (Phi(lo_k) plus the leftover target mass within interval k), not
|
||||
// a value renormalised to [0,1] -- inverting that gives s directly in the same units A and Sigma are in.
|
||||
// Clamped into [lo_k, hi_k) at the end to absorb rounding (a guard, not an invariant); erfinv's own
|
||||
// handling of the domain edges (+-infinity at p = 0 or 1) means the clamp is what actually keeps s finite
|
||||
// for an interval with negligible mass.
|
||||
private static float LateralDraw(Silhouette sil, float u)
|
||||
{
|
||||
var target = u * sil.POnHull;
|
||||
var cumulative = 0f;
|
||||
for (var k = 0; k < sil.Count; k++)
|
||||
{
|
||||
var interval = sil.Intervals[k];
|
||||
var loValue = Phi((interval.Lo - sil.A) / sil.Sigma);
|
||||
var hiValue = Phi((interval.Hi - sil.A) / sil.Sigma);
|
||||
var mass = hiValue - loValue;
|
||||
|
||||
if (target <= cumulative + mass || k == sil.Count - 1)
|
||||
{
|
||||
var p = clamp(loValue + (target - cumulative), loValue, hiValue);
|
||||
var s = sil.A + sil.Sigma * SQRT2 * erfinv(2f * p - 1f);
|
||||
return clamp(s, interval.Lo, interval.Hi);
|
||||
}
|
||||
|
||||
cumulative += mass;
|
||||
}
|
||||
|
||||
return sil.A; // sil.Count == 0: no metal in the shadow at all -- unreachable while POnHull > 0 gated Commit's roll
|
||||
}
|
||||
|
||||
// Cut 12.2 (docs/fire-control-cut.md, "the rules every sub-cut reads"): exact slab traversal along the
|
||||
// bearing at a fixed lateral offset s, replacing the old 0.5-step sampling march. Collects the occupied
|
||||
// cells whose shadow interval contains s, each with its own entry/exit parameter along b, ordered by
|
||||
// entry (ties broken by dot(cell, b), then cell index for full determinism), then walks forward while
|
||||
// consecutive cells are contiguous (`next.entry <= current.exit + 1e-4`) -- the first gap ends the walk,
|
||||
// the same rule Entity.cs's old march kept. A direct hit starts outside the hull (t -> -infinity), so the
|
||||
// walk's first element is the impact cell. Only that first element is read in 12.2; the rest is 12.3's
|
||||
// armour-first absorption march. Writes into the caller-supplied pooled buffer and returns the walked
|
||||
// count; never allocates on its own.
|
||||
public static int Lane(HullData hull, float2 b, float s, LaneCell[] buffer)
|
||||
{
|
||||
var ell = Lateral(b);
|
||||
var h = (abs(ell.x) + abs(ell.y)) / 2f;
|
||||
var coords = hull.Shape.Coordinates;
|
||||
|
||||
var n = 0;
|
||||
for (var i = 0; i < coords.Length; i++)
|
||||
{
|
||||
var c = (float2) coords[i];
|
||||
var centre = dot(c, ell);
|
||||
if (s < centre - h || s >= centre + h) continue; // outside this cell's own lateral shadow
|
||||
|
||||
if (!SlabAlongB(c, b, ell, s, out var entry, out var exit)) continue; // the shadow test is a conservative bound; the exact slab can still miss
|
||||
|
||||
buffer[n++] = new LaneCell { Cell = coords[i], Entry = entry, Exit = exit };
|
||||
}
|
||||
|
||||
Array.Sort(buffer, 0, n, Comparer<LaneCell>.Create((x, y) =>
|
||||
{
|
||||
var byEntry = x.Entry.CompareTo(y.Entry);
|
||||
if (byEntry != 0) return byEntry;
|
||||
var byProjection = dot((float2) x.Cell, b).CompareTo(dot((float2) y.Cell, b));
|
||||
if (byProjection != 0) return byProjection;
|
||||
var byX = x.Cell.x.CompareTo(y.Cell.x);
|
||||
return byX != 0 ? byX : x.Cell.y.CompareTo(y.Cell.y);
|
||||
}));
|
||||
|
||||
var walked = n > 0 ? 1 : 0;
|
||||
for (var i = 1; i < n; i++)
|
||||
{
|
||||
if (buffer[i].Entry > buffer[i - 1].Exit + 1e-4f) break;
|
||||
walked++;
|
||||
}
|
||||
return walked;
|
||||
}
|
||||
|
||||
// The slab (ray-box) intersection of point(t) = t*b + s*ell against the unit square centred on cell c, one
|
||||
// axis at a time: solving bAxis*t + val in [cLo, cHi] for each of x and y, then entry = the later of the
|
||||
// two lower bounds, exit = the earlier of the two upper bounds. A near-zero bAxis component means the ray
|
||||
// does not move along that axis at all, so it either always satisfies that axis's bound (no constraint on
|
||||
// t) or never does (no intersection).
|
||||
private static bool SlabAlongB(float2 c, float2 b, float2 ell, float s, out float entry, out float exit)
|
||||
{
|
||||
entry = float.NegativeInfinity;
|
||||
exit = float.PositiveInfinity;
|
||||
return SlabAxis(b.x, c.x - .5f, c.x + .5f, s * ell.x, ref entry, ref exit)
|
||||
&& SlabAxis(b.y, c.y - .5f, c.y + .5f, s * ell.y, ref entry, ref exit);
|
||||
}
|
||||
|
||||
private static bool SlabAxis(float bAxis, float lo, float hi, float val, ref float entry, ref float exit)
|
||||
{
|
||||
if (abs(bAxis) < 1e-9f)
|
||||
return val >= lo && val <= hi; // no motion along this axis: in bounds forever, or never
|
||||
|
||||
var t1 = (lo - val) / bAxis;
|
||||
var t2 = (hi - val) / bAxis;
|
||||
entry = max(entry, min(t1, t2));
|
||||
exit = min(exit, max(t1, t2));
|
||||
return entry <= exit;
|
||||
}
|
||||
|
||||
// The cells of the target's hull schematic actually occupied by `item` -- GearOccupancy is the one source
|
||||
|
|
@ -636,10 +804,21 @@ public struct PendingShot
|
|||
public float Penetration;
|
||||
public float DamageSpread;
|
||||
public DamageType DamageType;
|
||||
public float PBase;
|
||||
// Cut 12.2 (docs/fire-control-cut.md): renamed from PBase -- its meaning changes from "the whole shot's
|
||||
// frozen roll price" to "the shooter's own share of it," behind the same gates (Accuracy x PSensor). The
|
||||
// spread and hull factors are no longer frozen here; they are priced live at Commit, against the target's
|
||||
// facing then (Bearing timing).
|
||||
public float PFire;
|
||||
public float Tracking;
|
||||
public float Precision;
|
||||
|
||||
// Cut 12.2: frozen at Fire alongside the rest of the shooter's own decision (R10) -- the shot's
|
||||
// world-planar travel direction (FireControl.TravelDirection), and the weapon stats PSpread reads at
|
||||
// Commit against the target's live silhouette.
|
||||
public float2 TravelDirection;
|
||||
public float Spread;
|
||||
public float FireRange;
|
||||
|
||||
// Cut 6b, 6.2: the airburst payload, frozen at fire alongside everything else above. BurstRadius is zero
|
||||
// for a weapon without the Airburst flag (WeaponModifiers, Enums.cs) -- Step reads that zero as "resolve
|
||||
// with Apply," not a separate bool.
|
||||
|
|
@ -673,11 +852,46 @@ public struct FireControlDiagnostic
|
|||
public float Precision;
|
||||
public float Tracking;
|
||||
public float PSensor;
|
||||
// Cut 12.2 (optional, presentation): the shooter-side factor alone, Accuracy x PSensor -- the same value
|
||||
// Fire freezes into PendingShot.PFire.
|
||||
public float PFire;
|
||||
public float PSpread;
|
||||
public float POnHull;
|
||||
public float PBase;
|
||||
}
|
||||
|
||||
// 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.
|
||||
public struct Interval
|
||||
{
|
||||
public float Lo;
|
||||
public float Hi;
|
||||
}
|
||||
|
||||
// Cut 12.2: the hull's lateral shadow at one bearing, and the exact Gaussian mass it carries. Intervals holds
|
||||
// the merged, sorted shadow (only the first Count entries are valid -- the array is sized to the hull's own
|
||||
// occupied-cell count, not trimmed, to avoid a second allocation). A, Sigma and POnHull are read together by
|
||||
// the lateral draw; Span is read by PSpread so the two factors can never disagree about the target's size.
|
||||
public struct Silhouette
|
||||
{
|
||||
public Interval[] Intervals;
|
||||
public int Count;
|
||||
public float2 AimPoint;
|
||||
public float A;
|
||||
public float Sigma;
|
||||
public float Span;
|
||||
public float POnHull;
|
||||
}
|
||||
|
||||
// Cut 12.2: one occupied cell FireControl.Lane crossed at a fixed lateral offset, with its own slab entry/exit
|
||||
// parameter along the bearing. Entry ascending is nearest-to-farthest along the shot's own travel direction.
|
||||
public struct LaneCell
|
||||
{
|
||||
public int2 Cell;
|
||||
public float Entry;
|
||||
public float Exit;
|
||||
}
|
||||
|
||||
// Cut 3 (docs/fire-control-cut.md, 0b table): a commit. Created once, at ArrivalTime - CommitHorizon (or at
|
||||
// fire time when the flight is shorter than the horizon), immutable from then on, published once on
|
||||
// Zone.ShotCommitted and again (unchanged) on Zone.ShotResolved at arrival. Presentations are the only
|
||||
|
|
@ -695,6 +909,11 @@ public sealed class ShotOutcome
|
|||
// deciding it, and a shield that recharges mid-flight cannot retroactively dodge it.
|
||||
public bool ShieldBroken;
|
||||
public int2 Cell;
|
||||
// Cut 12.2 (R4): the committed geometry, frozen alongside the rest of the outcome -- the schematic-frame
|
||||
// bearing and the lateral offset the roll's placement drew. Apply reads these, not a live position or
|
||||
// facing (TurningAfterCommitChangesNothing).
|
||||
public float2 Bearing;
|
||||
public float Lateral;
|
||||
public float ArrivalIn;
|
||||
public DamageType DamageType;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue