From 7dbb3bcf7a971516bf65b93f27f6d60a1e93a325 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 24 Sep 2026 20:40:50 +0200 Subject: [PATCH] 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 --- Assets/Scripts/Gameplay/ActionGameManager.cs | 6 +- Assets/Scripts/ServerShared/Entity.cs | 15 +- Assets/Scripts/ServerShared/FireControl.cs | 453 +++++++++---- .../FireAuthorityTests.cs | 40 +- .../FireControlCut12Tests.cs | 625 ++++++++++++++++++ .../FireControlCut6dTests.cs | 137 +--- .../FireControlCut7Tests.cs | 2 +- .../FireControlCut9Tests.cs | 27 +- 8 files changed, 1043 insertions(+), 262 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 74aee4644..6d3c0491c 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -1314,7 +1314,11 @@ public class ActionGameManager : MonoBehaviour else { var pDeviation = FireControl.DeviationProbability(shot, CurrentEntity.Zone.Time, out var deviation); - pendingLine = $"shot {shot.ShotId}: dev {deviation:F1}/{shot.Tracking:F1} x{pDeviation:F3} estimate {shot.PBase * pDeviation:P1}"; + // Cut 12.2 (docs/fire-control-cut.md): retires the inline estimate (shot.PBase * pDeviation), + // a second, wrong copy of the model now that the target's facing enters at Commit -- this + // calls the same function Commit itself rolls against (TheHudEstimateIsTheCommitPrice). + var estimate = FireControl.CommitProbability(shot, CurrentEntity.Zone.Time, out _); + pendingLine = $"shot {shot.ShotId}: dev {deviation:F1}/{shot.Tracking:F1} x{pDeviation:F3} estimate {estimate:P1}"; } break; } diff --git a/Assets/Scripts/ServerShared/Entity.cs b/Assets/Scripts/ServerShared/Entity.cs index 3ce8f11b1..b30177b98 100644 --- a/Assets/Scripts/ServerShared/Entity.cs +++ b/Assets/Scripts/ServerShared/Entity.cs @@ -384,12 +384,13 @@ public abstract class Entity return float2(dot(worldPlanar, right), dot(worldPlanar, forward)); } - // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription, with two - // changes forced by R7 (the simulation is 2D): the hit cell is the rolled Cell FireControl already chose, - // not a UV texture coordinate; and the penetration march rotates the firer-to-target direction into this - // entity's own frame with a planar rotation by -Direction, in place of transform.InverseTransformDirection - // -- no 3D transform anywhere in the march. `hitDirection` is the planar (x,z) firer-to-target unit vector. - public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 hitDirection) + // Cut 3: the shape construction moved from EntityInstance.cs's Unity HullCollider subscription -- the hit + // cell is the rolled Cell FireControl already chose, not a UV texture coordinate. + // Cut 12.2 (docs/fire-control-cut.md): `bearing` is already in this entity's own schematic frame -- the + // committed ShotOutcome.Bearing FireControl.Commit computed once, at the commit tick -- so the ToSchematic + // call this used to make internally (12.1) is gone; ApplyHit no longer touches a world direction or this + // entity's own Direction at all. + public void ApplyHit(Entity source, int2 cell, float spread, float penetration, float damage, float2 bearing) { IncomingHit.OnNext(source); @@ -402,7 +403,7 @@ public abstract class Entity if (penetration > .5f) { - var penetrationVector = normalize(ToSchematic(hitDirection)); + var penetrationVector = normalize(bearing); var penetrationPoint = (float2) cell + float2(.5f); var penetrationDistance = 0f; diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 7626ad07e..35c5faff7 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -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.Shared.Rent(hullData.Shape.Coordinates.Length); + try + { + var count = Lane(hullData, bearing, lateral, buffer); + cell = count > 0 ? buffer[0].Cell : int2.zero; + } + finally + { + ArrayPool.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.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; } diff --git a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs index e92de5a72..3d08b0a24 100644 --- a/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs +++ b/tests/Aetheria.Shared.Tests/FireAuthorityTests.cs @@ -447,14 +447,23 @@ public sealed class FireAuthorityTests : IDisposable } // 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. + // p 1, the aimed item's durability falls -- the lateral draw still converges on the aimed item's own lane + // when the group is that tight, so this stays a same-lane-every-time assertion rather than a statistical + // one. Mutation: the draw ignores Aimed and always picks a uniform random hull lateral offset. + // Cut 12.2 (docs/fire-control-cut.md): aiming now only prices and draws the LATERAL offset (the item's + // own aim point still centres the scatter, same as before); depth along the shot's own travel direction is + // resolved by Lane to whichever occupied cell is nearest the entry surface, which for a solid hull is + // always a border cell, never a Tool item's own interior cell (Shape.Shrink). A direct, unpenetrating hit + // (penetration 0, the fixture's old default) can therefore only ever land on the border cell in the aimed + // item's own lane -- reaching the item itself now requires the same penetration march + // AimingAtAnItemCentresTheScatterOnItsLane and HitsLandOnTheFacingEdge exercise, so this fixture is given + // enough penetration (5, well past this 5x5 hull's own width) to march from the entry edge through to the + // interior cell the aimed item occupies. [Fact] public void AimedHitLandsOnSelectedItem() { EquippableItem aimedGear = null; - var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1000, armor: 0, + var e = Build(TestSettings(), damage: 50, velocity: 0, accuracy: 1, resolution: 1, spread: 0, precision: 1000, armor: 0, penetration: 5, beforeActivate: (items, shooter, target) => { // A second, distinct interior item on the target to aim at, equipped before Activate() (Entity. @@ -495,18 +504,26 @@ public sealed class FireAuthorityTests : IDisposable var occupantItem = e.Target.Equipment.Single(x => x.EquippableItem == occupant); var beforeHull = e.Target.Hull.Durability; - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, hitDirection: float2(0, 1)); + // Cut 12.2 (docs/fire-control-cut.md): ApplyHit's direction parameter is now an already-schematic + // bearing (the caller's job, formerly ApplyHit's own internal ToSchematic call) -- unread here anyway, + // since penetration 0 never enters the march. + e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 0, damage: 10, bearing: float2(0, 1)); Assert.Equal(0f, e.Target.Armor[cell.x, cell.y]); // 2 armor consumed Assert.Equal(0f, occupantItem.EquippableItem.Durability); // 3 item durability consumed Assert.Equal(beforeHull - 5f, e.Target.Hull.Durability, 2); // remaining 5 hits the hull } - // R7: the penetration march is planar, rotated into the target's own facing. Firing straight into a - // target's nose (hitDirection opposed to Direction) marches the hit shape toward the target's stern, along - // +Y in this hull's local grid regardless of which way the entity happens to be facing in world space. - // Mutation: skip rotating by Direction and march along the raw world hitDirection instead -- a target - // facing anywhere other than the world +Z axis would then penetrate the wrong cells. + // R7: the penetration march is planar, in the target's own schematic frame. Firing straight into a + // target's nose marches the hit shape toward the target's stern, along +Y in this hull's local grid + // regardless of which way the entity happens to be facing in world space. + // Cut 12.2 (docs/fire-control-cut.md): the world-to-schematic rotation this test pinned moved out of + // ApplyHit and into its caller (FireControl.Commit computes `bearing = target.ToSchematic(travelDirection)` + // once, at the commit tick, and passes the already-rotated bearing in) -- ApplyHit itself no longer reads + // Direction at all. This test now performs that same rotation itself, at the call site, to keep pinning + // the march's own planar behaviour once a schematic bearing is in hand; the end-to-end claim that + // Direction feeds the rotation correctly is what FireControlCut12Tests.HitsLandOnTheFacingEdge and + // TurningArmourIntoTheShotTakesItOnTheArmour now cover, through FireControl.Commit itself. [Fact] public void PenetrationMarchIsPlanar() { @@ -515,7 +532,8 @@ public sealed class FireAuthorityTests : IDisposable var cell = new int2(1, 0); var hitDirection = normalize(float2(1, 0)); // the shot arrived travelling along the target's own forward - e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, hitDirection: hitDirection); + var bearing = e.Target.ToSchematic(hitDirection); + e.Target.ApplyHit(e.Shooter, cell, spread: 0, penetration: 1.5f, damage: 30, bearing: bearing); // Rotating hitDirection into this Direction's frame (forward=(1,0), right=(0,-1)) makes the local // penetration vector (0,1): the march should have advanced from (1,0) into (1,1), consuming that diff --git a/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs new file mode 100644 index 000000000..55aa28ea1 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/FireControlCut12Tests.cs @@ -0,0 +1,625 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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.IO; +using System.Linq; +using CultMath; +using GameCult.Caching; +using UniRx; +using Xunit; +using static CultMath.math; +using float2 = CultMath.float2; +using float3 = CultMath.float3; +using int2 = CultMath.int2; + +// Cut 12.2 (docs/fire-control-cut.md, "Where a hit lands"): a hit's placement is priced and drawn from one +// silhouette, read at the bearing the target's facing implies AT COMMIT, not at fire. Builds its own +// fixtures, the convention every cut's test file follows. Fixtures follow 11.3: the shooter sits off the +// origin, fire times are nonzero, facings include ones where |fx| > |fy|, and weapon spread is nonzero +// wherever PSpread is asserted. +public sealed class FireControlCut12Tests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut12-" + Guid.NewGuid().ToString("N")); + private string Catalog => Path.Combine(_root, "Aetheria.cc"); + + public FireControlCut12Tests() => Directory.CreateDirectory(_root); + + private readonly List _openCaches = new List(); + + public void Dispose() + { + foreach (var c in _openCaches) c.Dispose(); + Directory.Delete(_root, true); + } + + private static GameplaySettings TestSettings() => new GameplaySettings + { + DefaultEntitySettings = new EntitySettings(), + Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } }, + QualityPriceModifier = new ExponentialLerp(), + TargetDetectionInfoThreshold = .1f, + TargetArmorInfoThreshold = .2f, + TargetGearInfoThreshold = .8f, + FiringArc = 170, + CommitHorizon = .5f, + SchematicCellSize = 1f, + UnaidedAccuracy = .05f, + UnaidedTracking = 10f, + UnaidedPrecision = .3f, + AgentMinHitProbability = 0f, + BeamResolveInterval = .1f + }; + + private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v }; + + private static Shape SolidShape(int w, int h) + { + var shape = new Shape(w, h); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + private sealed class Engagement + { + public ItemManager Items; + public Zone Zone; + public Ship Shooter; + public Ship Target; + public EquippedItem WeaponItem; + public InstantWeapon Weapon; + public HullData HullData; + public EquippedItem[] Markers; + } + + // A shooter off the origin (Cut 11.3), a target over a caller-supplied hull shape, a full-strength + // targeting system (Accuracy 1, Resolution huge, Tracking huge) so only the geometry under test moves the + // number, and an optional shield. markerCells equips the catalog's bare "Marker" Tool stub at each given + // interior cell -- returned in the same order -- for tests that need to name a specific cell (bow, stern). + private Engagement Build( + GameplaySettings settings, Shape hullShape, float precision, + float velocity = 0, float spread = 0, float2? targetFacing = null, + bool equipShield = false, float shieldCapacity = 30, + int2[] markerCells = null) + { + var hullData = new HullData + { + Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000, Armor = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + // The shooter's own hull is a fixed, always-roomy shape -- distinct from the target's caller-supplied + // hullShape, which some tests (BroadsideIsEasierThanHeadOn's 2-wide hull, TheSigmaFloorHoldsAtHalfACell's + // 1-wide lane) deliberately make too narrow to have any interior at all, so a Tool item (the shooter's + // own Targeting system) could never fit on it. + var shooterHullData = new HullData + { + Name = "ShooterHull", HullType = HullType.Ship, Shape = SolidShape(5, 5), Durability = 100000, Mass = 1000, + Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } } + }; + + var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true); + _openCaches.Add(cache); + cache.Upsert(new TestCatalogGlobal { Name = "Temperament" }); + cache.Upsert(hullData); + cache.Upsert(shooterHullData); + cache.Upsert(new GearData + { + Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new InstantWeaponData + { + Damage = Constant(5), Range = Constant(1000), MinRange = Constant(0), + Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0), + Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000), + DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } } + } } + }); + cache.Upsert(new GearData + { + Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new TargetingSystemData + { + Accuracy = Constant(1f), Resolution = Constant(1000f), Precision = Constant(precision), Tracking = Constant(100000f) + } } + }); + cache.Upsert(new GearData + { + Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } } + }); + cache.Upsert(new GearData + { + Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000, + Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } } + }); + cache.Upsert(new GearData + { + Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1000000, + MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000 + }); + cache.FlushAsync().Wait(); + + var ledger = new ProvenanceLedger(); + for (var i = 1; i <= 24; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 }; + var items = new ItemManager(cache, ledger, settings, _ => { }); + var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Cut12-" + Guid.NewGuid().ToString("N"), Owner = null }, null); + + EquippableItem Make(string name, int lot, float durability) => new EquippableItem + { + Data = items.ItemData.RefOf(items.ItemData.GetByName(name)), Durability = durability, Lot = lot + }; + + var lot = 1; + var hullRef = items.ItemData.RefOf(items.ItemData.GetByName("Hull")); + var shooterHullRef = items.ItemData.RefOf(items.ItemData.GetByName("ShooterHull")); + var shooter = new Ship(items, zone, new EquippableItem { Data = shooterHullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + var gun = Make("Gun", lot++, 1); + Assert.True(shooter.TryEquip(gun, new int2(0, 0))); + var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun); + var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon); + var targeting = Make("Targeting", lot++, 1); + Assert.True(shooter.TryFindSpace(targeting, out var tpos)); + Assert.True(shooter.TryEquip(targeting, tpos)); + + var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 1000000, Lot = lot++ }, new EntitySettings()); + + // A bystander item on the Hardpoint slot every hull shape has (position (0,0) needs no Shrink-interior, + // unlike a Tool) -- Entity.Update's `foreach (var item in _orderedEquipment)` NREs if an entity was + // never equipped with anything at all (_orderedEquipment stays null until TryEquip first assigns it), + // the same reason FireControlCut9Tests.BuildLockScenario equips its own bystander with a token gun. + var targetGun = Make("Gun", lot++, 1); + Assert.True(target.TryEquip(targetGun, new int2(0, 0))); + + var markers = markerCells?.Select(cell => + { + var m = Make("Marker", lot++, 1000000); + Assert.True(target.TryEquip(m, cell), $"marker must fit at {cell}"); + return target.Equipment.Single(x => x.EquippableItem == m); + }).ToArray(); + + if (equipShield) + { + var reactor = Make("Reactor", lot++, 10); + Assert.True(target.TryFindSpace(reactor, out var rpos)); + Assert.True(target.TryEquip(reactor, rpos)); + var shield = Make("Shield", lot++, 10); + Assert.True(target.TryFindSpace(shield, out var spos)); + Assert.True(target.TryEquip(shield, spos)); + } + + zone.Entities.Add(shooter); + zone.Entities.Add(target); + shooter.Activate(); + target.Activate(); + + // Cut 11.3: the shooter sits off the origin -- a shooter at float3.zero makes `target - source` and + // `target + source` the same vector, hiding a whole class of sign mutants. + shooter.Position = float3(37, 0, -11); + target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter + if (targetFacing != null) target.Direction = targetFacing.Value; + shooter.Target.Value = target; + shooter.EntityInfoGathered[target] = 1f; + shooter.SetIff(target, true); + if (equipShield) + { + target.Shield.Item.Enabled.Value = true; + for (var i = 0; i < 20; i++) zone.Update(.1f); // charge from empty (Cut 11's own Charge() convention) + } + + // Cut 11.3: fire times are nonzero -- warm up past a bare zone.Update(0f) so FireTime is never 0. + zone.Update(1f); + + return new Engagement + { + Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon, + HullData = hullData, Markers = markers + }; + } + + private static List FireMany(Engagement e, int count) + { + var outcomes = new List(); + e.Zone.ShotResolved.Subscribe(o => outcomes.Add(o)); + for (var i = 0; i < count; i++) + { + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(.01f); + } + return outcomes; + } + + // Independent reimplementation of FireControl.Lane's slab test, from the spec's own formulas, over the + // hull's public Shape.Coordinates -- used to check production's placement against an outcome's own frozen + // Bearing/Lateral without calling FireControl.Lane itself. + private static int2 NearestOccupiedCellAtLateral(HullData hull, float2 bearing, float lateral) + { + var ell = float2(-bearing.y, bearing.x); + var h = (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f; + int2? best = null; + var bestEntry = float.PositiveInfinity; + + foreach (var c in hull.Shape.Coordinates) + { + var centre = dot((float2) c, ell); + if (lateral < centre - h || lateral >= centre + h) continue; + + var cf = (float2) c; + var entry = float.NegativeInfinity; + var exit = float.PositiveInfinity; + var valid = true; + foreach (var axis in new[] { 0, 1 }) + { + var bAxis = axis == 0 ? bearing.x : bearing.y; + var cAxis = axis == 0 ? cf.x : cf.y; + var val = axis == 0 ? lateral * ell.x : lateral * ell.y; + var lo = cAxis - .5f; + var hi = cAxis + .5f; + if (Math.Abs(bAxis) < 1e-9f) + { + if (val < lo || val > hi) valid = false; + continue; + } + var t1 = (lo - val) / bAxis; + var t2 = (hi - val) / bAxis; + entry = Math.Max(entry, Math.Min(t1, t2)); + exit = Math.Min(exit, Math.Max(t1, t2)); + } + if (!valid || entry > exit) continue; + + if (entry < bestEntry) + { + bestEntry = entry; + best = c; + } + } + + Assert.True(best != null, "fixture: no occupied cell reached at this lateral offset"); + return best.Value; + } + + // BroadsideIsEasierThanHeadOn: kills the bearing being ignored and the old max(W,H) bound. A 2x12 hull is + // much easier to hit broadside (its long axis facing the shot) than head-on (its short axis facing it). + [Fact] + public void BroadsideIsEasierThanHeadOn() + { + float PAt(float2 facing) + { + var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, spread: 4, targetFacing: facing); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread; + } + + float POnHullAt(float2 facing) + { + var e = Build(TestSettings(), SolidShape(2, 12), precision: .5f, targetFacing: facing); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull; + } + + // The shooter fires from world +z toward the target (TravelDirection ~ world +z, since the target sits + // straight ahead). Facing (0,1) (bow toward the shooter, forward parallel to travel) puts the ship's + // SHORT axis (width 2) across the bearing -- head-on. Facing (1,0) puts its LONG axis (height 12) + // across the bearing -- broadside. + var headOnPSpread = PAt(float2(0, 1)); + var broadsidePSpread = PAt(float2(1, 0)); + Assert.True(broadsidePSpread > headOnPSpread, + $"broadside PSpread ({broadsidePSpread}) must exceed head-on PSpread ({headOnPSpread})"); + + var headOnPOnHull = POnHullAt(float2(0, 1)); + var broadsidePOnHull = POnHullAt(float2(1, 0)); + Assert.True(broadsidePOnHull > headOnPOnHull, + $"broadside pOnHull ({broadsidePOnHull}) must exceed head-on pOnHull ({headOnPOnHull})"); + } + + // TurningArmourIntoTheShotTakesItOnTheArmour (the ruling's own case): a slow shot at a target whose stern + // is exposed at Fire. The target turns its armoured bow to face the incoming shot (present the bow toward + // where the shot is travelling FROM) before the shot commits. Every hit must land on the bow row -- + // Bearing timing reads the facing live, at Commit, not frozen at Fire. + [Fact] + public void TurningArmourIntoTheShotTakesItOnTheArmour() + { + var settings = TestSettings(); + // Flight time well above CommitHorizon (.5s): range 100 at velocity 20 -> 5s flight, commits at 4.5s. + var e = Build(settings, SolidShape(3, 11), precision: .4f, velocity: 20, targetFacing: null); + + var outcomes = new List(); + using var r = e.Zone.ShotResolved.Subscribe(outcomes.Add); + + // Repeat the same fire-then-turn sequence several times (the roll can still miss outright) and check + // the set of hits, not one shot. + for (var i = 0; i < 60; i++) + { + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var travelDirection = pending.TravelDirection; + + // At Fire: bow faces the same way the shot travels -- the stern is exposed to the incoming shot. + e.Target.Direction = travelDirection; + e.Zone.Update(1f); // short of commit (4.5s) + + // Before commit: turn the bow to face the incoming shot head-on. + e.Target.Direction = -travelDirection; + e.Zone.Update(4f); // past commit (4.5s), short of arrival (5s) + e.Zone.Update(2f); // past arrival + } + + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 10, $"expected a healthy number of hits to check, got {hits.Count}"); + foreach (var outcome in hits) + Assert.True(outcome.Cell.y >= 5.5f, $"expected every hit to land on the bow row (y>=5.5), got cell {outcome.Cell}"); + } + + // TurningAfterCommitChangesNothing (R4, closes L464/L465): after commit, turning the target and moving the + // shooter must not change where the damage actually lands -- Apply reads no live position or facing. The + // armor cell that actually takes damage is checked against an independent lane recompute from the + // committed Bearing/Lateral, not from the post-turn facing. + [Fact] + public void TurningAfterCommitChangesNothing() + { + var settings = TestSettings(); + var e = Build(settings, SolidShape(7, 7), precision: .5f, velocity: 20); // 5s flight, commits at 4.5s + + ShotOutcome committed = null; + using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + // The roll can still miss outright -- retry (a fresh shot each time; the previous miss already + // resolved and left the pending queue empty) until one hits. + for (var attempt = 0; attempt < 30 && (committed == null || !committed.Hit); attempt++) + { + committed = null; + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(4.6f); // past commit (4.5s), short of arrival (5s) + } + Assert.NotNull(committed); + Assert.True(committed.Hit, "precondition: this fixture's shot must hit"); + + var expectedCell = NearestOccupiedCellAtLateral(e.HullData, committed.Bearing, committed.Lateral); + Assert.Equal(expectedCell, committed.Cell); // sanity: production's own Lane agrees with the independent recompute + + var before = (float[,]) e.Target.Armor.Clone(); + + // After commit, before arrival: the target turns 90 degrees and the shooter moves. + e.Target.Direction = float2(1, 0); + e.Shooter.Position += float3(500, 0, 500); + + e.Zone.Update(2f); // past arrival (5s) + + Assert.True(e.Target.Armor[expectedCell.x, expectedCell.y] < before[expectedCell.x, expectedCell.y], + "the committed impact cell must be the one that actually took damage, regardless of the post-commit turn"); + } + + // PlacementAndProbabilityShareOneSilhouette replaces 6d's PlacementAndProbabilityShareOneKernel: pins that + // CommitProbability's number and Commit's own placement are read from the exact same Silhouette. A + // diagonal bearing (|fx| ~ |fy|) makes individual cells' raw shadow intervals overlap heavily before + // merging, so a skipped merge (double-counted mass) or a swapped ell/b would show up here. + [Fact] + public void PlacementAndProbabilityShareOneSilhouette() + { + var settings = TestSettings(); + const float precision = .45f; + var hullShape = SolidShape(7, 9); + // Aim off the hull's own centre of mass (near a corner) so the Gaussian's mass is not symmetric + // inside the shadow -- a perturbed sigma or a mis-signed ell moves the analytic and reported numbers + // apart even though both would otherwise report something close to 1. + var aim = new int2(1, 1); + var e = Build(settings, hullShape, precision, spread: 0, targetFacing: normalize(float2(1f, 1f)), + markerCells: new[] { aim }); + Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0])); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + Assert.Same(e.Markers[0], pending.Aimed); + + var bearing = normalize(e.Target.ToSchematic(pending.TravelDirection)); + var ell = float2(-bearing.y, bearing.x); + var sigma = Math.Max(1f / precision, .5f); + var aimPoint = (float2) aim; // ResolveAimPoint on a single-cell marker: that cell itself + var a = dot(aimPoint, ell); + + // Independent shadow: raw per-cell intervals from the hull's own public Shape.Coordinates, sorted and + // merged by this test, not by calling FireControl.Silhouette. + var raw = e.HullData.Shape.Coordinates + .Select(c => dot((float2) c, ell)) + .Select(centre => (Lo: centre - (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f, Hi: centre + (Math.Abs(ell.x) + Math.Abs(ell.y)) / 2f)) + .OrderBy(iv => iv.Lo) + .ToList(); + var merged = new List<(float Lo, float Hi)>(); + foreach (var iv in raw) + { + if (merged.Count > 0 && iv.Lo <= merged[merged.Count - 1].Hi) + merged[merged.Count - 1] = (merged[merged.Count - 1].Lo, Math.Max(merged[merged.Count - 1].Hi, iv.Hi)); + else + merged.Add(iv); + } + + // Numeric integral of the Gaussian pdf over the merged union -- independent of erf, and of any private + // FireControl helper. + double Mass((float Lo, float Hi) iv) + { + const int steps = 4000; + var width = (iv.Hi - iv.Lo) / steps; + if (width <= 0) return 0; + double sum = 0; + for (var i = 0; i <= steps; i++) + { + var x = iv.Lo + i * width; + var z = (x - a) / sigma; + var pdf = Math.Exp(-z * z / 2.0) / (sigma * Math.Sqrt(2.0 * Math.PI)); + sum += pdf * (i == 0 || i == steps ? .5 : 1.0); + } + return sum * width; + } + var expectedPOnHull = merged.Sum(Mass); + Assert.True(expectedPOnHull > .05 && expectedPOnHull < .95, $"fixture: pOnHull ({expectedPOnHull}) must be non-degenerate to exercise the merge, got a saturated or collapsed value"); + + var commitP = FireControl.CommitProbability(pending, e.Zone.Time, out var sil); + // Fixture isolation: PFire = Accuracy(1) x PSensor(1, full reveal) = 1; pDeviation = 1 (target hasn't + // moved since FireTargetPosition was frozen); PSpread = 1 (Spread 0's own early-out). So the reported + // commit price collapses to exactly pOnHull, the number this test computed independently. + Assert.Equal((float) expectedPOnHull, commitP, 3); + Assert.Equal((float) expectedPOnHull, sil.POnHull, 3); + + // Empirical: the hit rate and the per-interval Lateral shares both match the analytic union. + const int shots = 4000; + var outcomes = FireMany(e, shots); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > shots * expectedPOnHull * .5, $"expected roughly {shots * expectedPOnHull:0} hits, got {hits.Count}"); + Assert.True(Math.Abs(hits.Count / (double) shots - expectedPOnHull) < .05, $"empirical hit rate {hits.Count / (double) shots:F3} does not track analytic pOnHull {expectedPOnHull:F3}"); + + foreach (var (lo, hi) in merged) + { + var expectedShare = Mass((lo, hi)) / expectedPOnHull; + if (expectedShare < .05) continue; // too little mass to measure reliably over 4000 shots + var empiricalShare = hits.Count(o => o.Lateral >= lo - 1e-3f && o.Lateral < hi + 1e-3f) / (double) hits.Count; + Assert.True(Math.Abs(expectedShare - empiricalShare) < .1, $"interval [{lo:F2},{hi:F2}): expected share {expectedShare:F3}, empirical {empiricalShare:F3}"); + } + } + + // HitsLandOnTheFacingEdge supersedes EveryHitLandsOnMetal: on a concave (holed) hull, every hit's Cell is + // occupied, AND nothing occupied precedes it in its own lane -- the test recomputes the lane from the + // outcome's own frozen Bearing/Lateral, independently of FireControl.Lane, and checks production picked + // the nearest cell, not merely an occupied one. + [Fact] + public void HitsLandOnTheFacingEdge() + { + var shape = new Shape(7, 7); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + shape[new int2(3, 3)] = false; // the hole + + var aim = new int2(1, 1); + var e = Build(TestSettings(), shape, precision: .3f, targetFacing: normalize(float2(.8f, .6f)), markerCells: new[] { aim }); + Assert.True(e.Shooter.TrySelectTargetItem(e.Markers[0])); + + var outcomes = FireMany(e, 1500); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 100, $"expected a healthy number of hits to check, got {hits.Count}"); + + foreach (var outcome in hits) + { + Assert.True(shape[outcome.Cell], $"hit landed on {outcome.Cell}, not part of the hull's own schematic"); + var expected = NearestOccupiedCellAtLateral(e.HullData, outcome.Bearing, outcome.Lateral); + Assert.Equal(expected, outcome.Cell); + } + } + + // AimingAtAnItemCentresTheScatterOnItsLane closes L585/L587 and replaces AimingAtTheSternHitsTheStern, + // whose fixture fired from the target's own stern and would pass for the wrong reason (any placement rule + // that merely biases toward whatever is nearest the shooter, rather than toward the aim point specifically, + // would also pass a stern-fired, stern-aimed shot). From broadside, hits aimed at the stern land mostly in + // the stern half and hits aimed at the bow mostly in the bow half; from dead astern, hits aimed at the bow + // land on the stern edge (the near edge, since the aim point pulls the group toward the bow but every cell + // between the stern edge and the bow marker is on the lane first). + [Fact] + public void AimingAtAnItemCentresTheScatterOnItsLane() + { + var settings = TestSettings(); + var hullShape = SolidShape(3, 11); + var bow = new int2(1, 9); // y=9: firmly in the bow half (y>=5.5) + var stern = new int2(1, 1); // y=1: firmly in the stern half + + double SternFraction(float2 facing, int2 aimCell) + { + var e = Build(settings, hullShape, precision: .6f, targetFacing: facing, markerCells: new[] { aimCell }); + var aimed = e.Markers[0]; + Assert.True(e.Shooter.TrySelectTargetItem(aimed)); + Assert.Same(aimed, e.Shooter.ResolvedTargetItem); + + var outcomes = FireMany(e, 600); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 100, $"expected a healthy number of hits, got {hits.Count}"); + return hits.Count(o => o.Cell.y < 5.5f) / (double) hits.Count; + } + + // Broadside: forward perpendicular to the shot's travel direction (world ~+z), so both bow and stern + // are roughly equidistant from the shooter and the aim point, not the entry side, drives the scatter. + var sternAimedBroadside = SternFraction(float2(1, 0), stern); + var bowAimedBroadside = SternFraction(float2(1, 0), bow); + Assert.True(sternAimedBroadside > .7, $"broadside, aimed at the stern: expected mostly stern hits, got sternFraction={sternAimedBroadside}"); + Assert.True(bowAimedBroadside < .3, $"broadside, aimed at the bow: expected mostly bow hits, got sternFraction={bowAimedBroadside}"); + + // Dead astern: forward parallel to the shot's travel direction (bow points away from the shooter), so + // the shot enters through the stern regardless of aim -- aiming at the bow still lands on the near + // (stern) edge, because the stern cells are on the lane first. + var bowAimedAstern = SternFraction(float2(0, 1), bow); + Assert.True(bowAimedAstern > .7, $"dead astern, aimed at the bow: expected the near (stern) edge to still take the hits, got sternFraction={bowAimedAstern}"); + } + + // TheHudEstimateIsTheCommitPrice: for an in-flight shot, the value the HUD's own estimate (through + // FireControl.CommitProbability, the function ActionGameManager's pending-shot line now calls) and the p + // Commit itself rolls against are the same number at the commit tick. Kills a second formula. + [Fact] + public void TheHudEstimateIsTheCommitPrice() + { + var e = Build(TestSettings(), SolidShape(5, 7), precision: .5f, velocity: 20); // 5s flight + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); // still short of the 4.5s commit + + var pending = e.Zone.PendingShots.Single(s => s.ShotId == shotId); + var hudEstimate = FireControl.CommitProbability(pending, e.Zone.Time, out _); + + ShotOutcome committed = null; + var draws = 0; + using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; draws++; }); + e.Zone.Update(4f); // now past commit + Assert.NotNull(committed); + Assert.Equal(1, draws); + + // The commit tick's own price, recomputed with the pending shot as it stood before Commit mutated its + // Committed flag -- CommitProbability is pure, so calling it again after the fact at the same `now` + // reproduces the exact p Commit rolled against. + var commitTickEstimate = FireControl.CommitProbability(pending, 1f + 4f, out _); + Assert.Equal(hudEstimate, commitTickEstimate, 5); // the target never moved or turned between the two reads + } + + // TheSigmaFloorHoldsAtHalfACell: pOnHull at Precision 1000 equals pOnHull at Precision 2 on a one-cell + // lane. Cut 12.2's exact integral no longer collapses on its own (POnHullNeverCollapsesAboveThePrecisionCliff + // no longer catches a deleted floor), so this is the test that kills "delete SigmaFloor" -- without the + // floor, sigma at Precision 1000 would be 0.001, and the Gaussian would collapse almost entirely inside a + // single cell's own width rather than sharing the whole one-cell lane the way Precision 2 (sigma .5, + // already at the floor) does. + [Fact] + public void TheSigmaFloorHoldsAtHalfACell() + { + // A 1-wide, 9-tall hull: every occupied cell's shadow along a dead-ahead bearing collapses onto the + // same single-cell-wide lane (ell has one axis zero, so h and the per-cell interval width degenerate + // to the schematic's own cell width) -- "a one-cell lane" per the spec's own name for this test. A + // 1-wide hull has no interior cell at all (Shrink needs a full ring), so this fires unaimed -- the + // hull's own centre of mass, (0,4), is already the middle of the lane. + var shape = SolidShape(1, 9); + + float POnHullAt(float precision) + { + var e = Build(TestSettings(), shape, precision); + return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).POnHull; + } + + var at2 = POnHullAt(2f); + var at1000 = POnHullAt(1000f); + Assert.Equal(at2, at1000, 4); + } + + // ShieldIsOmnidirectional: an active shield absorbs the same shot identically from bow, stern and beam -- + // the shield gate in Commit reads only DamageType/Damage, never the bearing this cut introduces. + [Fact] + public void ShieldIsOmnidirectional() + { + var settings = TestSettings(); + bool AbsorbsFrom(float2 facing) + { + var e = Build(settings, SolidShape(5, 5), precision: 1f, targetFacing: facing, equipShield: true, shieldCapacity: 1000); + var before = e.Target.Hull.Durability; + var outcomes = FireMany(e, 50); + var hits = outcomes.Where(o => o.Hit).ToList(); + Assert.True(hits.Count > 5, $"expected a healthy number of hits, got {hits.Count}"); + Assert.True(hits.All(o => o.Shielded), "every hit must be absorbed by the shield regardless of facing"); + Assert.Equal(before, e.Target.Hull.Durability); + return true; + } + + Assert.True(AbsorbsFrom(float2(0, 1))); // bow toward the shot + Assert.True(AbsorbsFrom(float2(0, -1))); // stern toward the shot + Assert.True(AbsorbsFrom(float2(1, 0))); // beam + } +} diff --git a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs index 326e7c378..b45a1d20c 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut6dTests.cs @@ -195,33 +195,12 @@ public sealed class FireControlCut6dTests : IDisposable return outcomes; } - // A solid 3-wide, 11-tall hull: a "Stern Marker" item sits at (1,1), near one end. y < 5.5 is the stern - // half, y >= 5.5 the bow half -- neither half is the aim point itself, so the split is a genuine - // spatial-spread question and not just "did it land on the exact aimed cell." - private static Shape SolidShape(int w, int h) - { - var shape = new Shape(w, h); - foreach (var cell in shape.AllCoordinates) shape[cell] = true; - return shape; - } - - // AimingAtTheSternHitsTheStern: with a mid-range Precision, hits aimed at a stern item land in the stern - // half far more often than the bow half. Mutation: restore the uniform-random branch. Must die -- this is - // the assertion the shipped coin flip would fail (a coin flip's "miss the item" branch is uniform over the - // whole hull, so roughly half of it lands in either half regardless of aim). - [Fact] - public void AimingAtTheSternHitsTheStern() - { - var e = Build(TestSettings(), SolidShape(3, 11), precision: .5f, markerCell: new int2(1, 1)); - - var outcomes = FireMany(e, 300); - var hits = outcomes.Where(o => o.Hit).ToList(); - Assert.True(hits.Count > 50, $"expected a healthy number of hits to measure a distribution from, got {hits.Count}"); - - var stern = hits.Count(o => o.Cell.y < 5.5f); - var sternFraction = (float) stern / hits.Count; - Assert.True(sternFraction > .8f, $"expected the large majority of hits aimed at (1,1) to land in the stern half (y<5.5), got sternFraction={sternFraction} over {hits.Count} hits"); - } + // AimingAtTheSternHitsTheStern is superseded by FireControlCut12Tests.AimingAtAnItemCentresTheScatterOnItsLane + // (docs/fire-control-cut.md, Cut 12.2): this fixture fired from the target's own stern at a stern-aimed + // marker, so a placement rule that merely biases toward whatever is nearest the shooter -- not toward the + // aim point specifically -- would also have passed it. The replacement fires from broadside and from dead + // astern, and checks both a stern- and a bow-aimed shot from each, which a nearest-the-entry-side bug + // cannot pass. // A 5x5 blob (x:0-4, y:0-4) plus a 3x3 "pod" (x:6-8, y:1-3) joined to it by a single-cell neck at (5,2) -- // an appendage connected to the main mass at only one point, the schematic shape of a thin limb. The pod's @@ -251,6 +230,11 @@ public sealed class FireControlCut6dTests : IDisposable // design occupies). Mutation: drop pOnHull from the probability. Must die -- with pOnHull gone, aiming at // the limb and aiming at the centre of mass score identically (Accuracy * pSensor * pSpread, no spatial // term at all). + // Cut 12.2 (docs/fire-control-cut.md): pOnHull is now the exact 1D integral of the Gaussian over the + // target's lateral shadow rather than a 2D discrete-cell sum, but the shape of the rule is unchanged -- a + // limb's shadow interval is a small sliver of the hull's own span, so far less of the Gaussian mass + // centred on it actually falls on metal than when the same Gaussian is centred on the much wider mass of + // cells around the centre of mass. [Fact] public void ThinLimbCostsHitChance() { @@ -283,95 +267,14 @@ public sealed class FireControlCut6dTests : IDisposable $"got {gaps[i - 1].Gap} then {gaps[i].Gap}"); } - // The one kernel, replicated independently here (not by calling FireControl's private helpers) from the - // spec's own formula: w(cell) = exp(-d^2 / 2*sigma^2), sigma = 1/Precision, pOnHull = sum(w) / (2*pi*sigma^2). - // Used to compute an expected value from public inputs (the hull's own Shape and the authored Precision) - // and check production against it, rather than trusting production to grade its own homework. - private static (int2[] Cells, float[] Weights, float Total) ExpectedKernel(HullData hull, float2 aim, float precision) - { - var sigma = 1f / precision; - var coords = hull.Shape.Coordinates; - var weights = new float[coords.Length]; - var total = 0f; - for (var i = 0; i < coords.Length; i++) - { - var w = MathF.Exp(-CultMath.math.lengthsq((float2) coords[i] - aim) / (2f * sigma * sigma)); - weights[i] = w; - total += w; - } - return (coords, weights, total); - } + // PlacementAndProbabilityShareOneKernel is superseded by + // FireControlCut12Tests.PlacementAndProbabilityShareOneSilhouette (docs/fire-control-cut.md, Cut 12.2): + // the 2D discrete-cell kernel this test's ExpectedKernel replicated is gone -- Silhouette replaces it with + // an exact 1D Gaussian integral over the hull's lateral shadow, which the new test recomputes + // independently by numeric integration over a union of intervals it builds itself. - // PlacementAndProbabilityShareOneKernel: the empirical on-hull rate over many seeded draws matches the - // pOnHull the probability reported, within tolerance. Mutation: perturb sigma in one of the two call sites - // only. Must die -- this is the test that would catch the two functions drifting apart. - // - // Two independent checks against one independently-recomputed expectation: - // 1. HitProbability's reported number (== pOnHull exactly, every other factor neutralised to 1 by Build) - // must match the analytic pOnHull this test computes from the documented formula. A sigma perturbed - // only in HitProbability's own call to the kernel shows up here. - // 2. The empirical distribution of landing cells, over many seeded shots, must match the analytic - // per-cell weight shares this test computes from the same formula. A sigma perturbed only in Commit's - // call to the kernel (placement) leaves check 1 untouched but shows up here -- placement, not the roll, - // is what it drives. - [Fact] - public void PlacementAndProbabilityShareOneKernel() - { - var hullShape = BlobWithLimb(out var limb); - const float precision = 1f; - var e = Build(TestSettings(), hullShape, precision, markerCell: limb); - - var (cells, weights, total) = ExpectedKernel(e.HullData, limb, precision); - var expectedPOnHull = Math.Min(1f, total / (2f * MathF.PI * (1f / precision) * (1f / precision))); - - var reportedPBase = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - Assert.Equal(expectedPOnHull, reportedPBase, 3); - - const int shots = 2500; - var outcomes = FireMany(e, shots); - var hits = outcomes.Where(o => o.Hit).ToList(); - Assert.True(hits.Count > shots * expectedPOnHull * .5f, $"expected roughly {shots * expectedPOnHull:0} hits, got {hits.Count}"); - - // Check the two heaviest cells (the limb itself, and its one blob-side neighbour) -- together they - // carry most of the kernel's mass at this sigma, so their empirical share is the most sensitive, - // least noisy signal a perturbed placement sigma would move. - var byWeightDesc = Enumerable.Range(0, cells.Length).OrderByDescending(i => weights[i]).Take(2).ToArray(); - foreach (var i in byWeightDesc) - { - var expectedFraction = weights[i] / total; - var empiricalFraction = (float) hits.Count(o => o.Cell.Equals(cells[i])) / hits.Count; - Assert.True(Math.Abs(expectedFraction - empiricalFraction) < .07f, - $"cell {cells[i]}: expected fraction {expectedFraction:0.0000}, empirical {empiricalFraction:0.0000} over {hits.Count} hits"); - } - } - - // A 7x7 hull with a hole at its own centre (3,3) -- the bounding box has 49 cells, the schematic only 48. - // The aim point (1,1) is chosen far enough from the hole that it is still a valid interior cell (Shrink - // excludes not just the border but every cell touching the hole itself, which rules out the whole 3x3 - // block around it). EveryHitLandsOnMetal: no committed hit ever resolves to an unoccupied cell, across - // seeds and aim points including extremities. Mutation: draw from the hull's full bounding box - // (Shape.AllCoordinates) instead of its occupied cells (Shape.Coordinates) -- with a real hole to fall - // into, that mutant lands on the missing centre cell often enough over many draws to be caught, where a - // solid rectangle never would be. - [Fact] - public void EveryHitLandsOnMetal() - { - var shape = new Shape(7, 7); - foreach (var cell in shape.AllCoordinates) shape[cell] = true; - shape[new int2(3, 3)] = false; // the hole - - var aimPoint = new int2(1, 1); - // .Loose grouping (low Precision -> wide sigma): plenty of mass would spill toward the hole. - var e = Build(TestSettings(), shape, precision: .3f, markerCell: aimPoint); - - // 2000 shots at pOnHull ~.38 (~760 expected hits): the bounding-box-instead-of-Coordinates mutation - // would expect ~15 of those to land on the missing (3,3) specifically (1/49 uniform chance per hit), - // reliably enough to die even though the correct kernel puts exactly zero weight there. - var outcomes = FireMany(e, 2000); - var hits = outcomes.Where(o => o.Hit).ToList(); - Assert.True(hits.Count > 100, $"expected a healthy number of hits to check, got {hits.Count}"); - - foreach (var outcome in hits) - Assert.True(shape[outcome.Cell], $"hit landed on {outcome.Cell}, which is not part of the hull's own schematic"); - } + // EveryHitLandsOnMetal is superseded by FireControlCut12Tests.HitsLandOnTheFacingEdge (docs/fire-control-cut.md, + // Cut 12.2): the new test pins the stronger rule directly -- not merely "occupied," but "nothing occupied + // precedes it in its own lane" -- recomputed independently from the outcome's own frozen Bearing/Lateral on + // a concave (holed) hull. } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs index d905bb77c..c278bcf58 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut7Tests.cs @@ -186,7 +186,7 @@ public sealed class FireControlCut7Tests : IDisposable var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); var shot = e.Zone.PendingShots.Single(s => s.ShotId == shotId); - Assert.True(shot.PBase > 0f); // the shot is still worth taking -- this isn't a blackout, just a lost aim point + Assert.True(shot.PFire > 0f); // the shot is still worth taking -- this isn't a blackout, just a lost aim point Assert.Null(shot.Aimed); // the frozen snapshot must not carry an aim point reveal no longer supports } diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index 3e981bbf3..9ac0ea9fd 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -140,13 +140,17 @@ public sealed class FireControlCut9Tests : IDisposable return new DieEngagement { Items = items, Zone = zone, Shooter = shooter, Target = target }; } - // Fires one synthetic shot with an exact ShotId and PBase, bypassing FireControl.Fire's own Accuracy/ + // Fires one synthetic shot with an exact ShotId and PFire, bypassing FireControl.Fire's own Accuracy/ // Resolution/Spread/pOnHull arithmetic -- 9.1's tests own the roll, not the factors that feed it. // FireTargetPosition/Velocity match the target's actual (stationary) position and FireTime equals the - // zone's own time, so DeviationProbability commits to exactly 1 and PBase is the one number Commit's die - // reads. CommitTime/ArrivalTime are set in the past so the shot commits and resolves inside the very - // next zone.Update call, synchronously, before this method returns. - private bool FireSynthetic(DieEngagement e, int shotId, float pBase, float precision = 1000f) + // zone's own time, so DeviationProbability commits to exactly 1. Spread stays 0 (PSpread's own early-out, + // neutral regardless of silhouette) and TravelDirection points straight at the target through a + // full-strength, unaimed silhouette (Aimed unset, so ResolveAimPoint falls back to the hull's own centre + // of mass; SolidShape(5,5) at this cut's floored sigma puts every real shadow interval many sigma from + // the hull's edge), so pOnHull sits indistinguishable from 1 -- PFire is then the one number Commit's die + // reads, same as PBase was before 12.2. CommitTime/ArrivalTime are set in the past so the shot commits and + // resolves inside the very next zone.Update call, synchronously, before this method returns. + private bool FireSynthetic(DieEngagement e, int shotId, float pFire, float precision = 1000f) { bool? hit = null; using var sub = e.Zone.ShotResolved.Subscribe(o => @@ -162,9 +166,12 @@ public sealed class FireControlCut9Tests : IDisposable Penetration = 0f, DamageSpread = 0f, DamageType = DamageType.Kinetic, - PBase = pBase, + PFire = pFire, Tracking = 1e6f, Precision = precision, + TravelDirection = normalize((e.Target.Position - e.Shooter.Position).xz), + Spread = 0f, + FireRange = length((e.Target.Position - e.Shooter.Position).xz), FireTargetPosition = e.Target.Position, FireTargetVelocity = float3.zero, FireTime = e.Zone.Time, @@ -339,8 +346,12 @@ public sealed class FireControlCut9Tests : IDisposable // cell, because the discrete kernel sum badly undershoots the continuous 2*pi*sigma^2 denominator // against an aim point that is not exactly on a cell centre -- true of every shipped hull (no centre of // mass is integral). A 7x13 solid hull reproduces the same off-integer-centre shape without needing the - // real catalog open in this project. Mutation: delete the sigma floor -- pOnHull at Precision 1000 - // collapses back toward zero without it. + // real catalog open in this project. + // Cut 12.2 (docs/fire-control-cut.md): Silhouette replaces the discrete sum with an exact 1D Gaussian + // integral over the hull's lateral shadow, which does not undershoot at any sigma -- so deleting + // SigmaFloor no longer reproduces the collapse this test was written to catch (TheSigmaFloorHoldsAtHalfACell + // is what now kills that mutant). This test is kept anyway as a plain regression pin: pOnHull must stay + // well clear of zero, and non-collapsing, across the whole Precision domain, whichever formula computes it. [Fact] public void POnHullNeverCollapsesAboveThePrecisionCliff() {