From b0fd3cd06c7715040584f70a958ec9d714739bd5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 16:39:52 +0200 Subject: [PATCH] Cut 11 second round: one owner for PBase, and tests for what the 51 survivors showed Inspect.PBase is HitProbability's own answer instead of a second product of the factors. New behavioural tests pin a beam shot carrying its override damage, a fitted targeting system forgiving a jink, a shot orphaned before commit committing as a plain miss, and the HUD's arrival countdown. The tautological damage assertion in BeamRollsPerInterval is deleted. Stryker on FireControl.cs: 51 survivors to 43, all triaged in the map. Co-Authored-By: Claude Opus 5 --- Assets/Scripts/ServerShared/FireControl.cs | 8 +- .../FireControlCut11Tests.cs | 73 ++++++++++++++++- .../FireControlCut4Tests.cs | 3 +- .../FireControlCut9Tests.cs | 80 ++++++++++--------- 4 files changed, 121 insertions(+), 43 deletions(-) diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 4ba600de2..2729705fb 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -170,7 +170,8 @@ public static class FireControl // Presentation only: the debug HUD's view of exactly the factors HitProbability multiplies. It computes // every factor unconditionally, gates included, because a HUD wants to see why a shot is impossible -- // which is precisely why the hot path must not be routed through it. Both read the same factor - // functions below, so the HUD cannot grow a second, subtly different model. + // functions below, and PBase is HitProbability's own answer, so the gates and the product have one + // owner and the HUD cannot grow a second, subtly different model. public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target) { var settings = source.ItemManager.GameplaySettings; @@ -200,8 +201,7 @@ public static class FireControl diagnostic.PSpread = PSpread(weapon, targetHull, diagnostic.Range, settings); diagnostic.POnHull = POnHull(source, target, targetHull, diagnostic.Precision); - if (diagnostic.Visible && diagnostic.InRange && diagnostic.Locked && diagnostic.InArc) - diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull; + diagnostic.PBase = HitProbability(weapon, source, target); return diagnostic; } @@ -260,7 +260,7 @@ public static class FireControl // 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 = target != null ? HitProbability(weapon, source, target) : 0f; + var pBase = HitProbability(weapon, source, target); var targetVelocity = float3.zero; var targetPosition = source.Position; diff --git a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs index a4d9172fc..8e9d042c3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut11Tests.cs @@ -264,7 +264,8 @@ public sealed class FireControlCut11Tests : IDisposable { var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s ShotOutcome committed = null, resolved = null; - using var c = e.Zone.ShotCommitted.Subscribe(o => committed = o); + var commits = 0; + using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; }); using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o); FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); @@ -272,6 +273,8 @@ public sealed class FireControlCut11Tests : IDisposable Assert.NotNull(committed); Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit"); Assert.Null(resolved); + // What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s. + Assert.Equal(.3f, committed.ArrivalIn, 3); e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives e.Zone.Update(1f); @@ -280,6 +283,74 @@ public sealed class FireControlCut11Tests : IDisposable Assert.Equal(committed.ShotId, resolved.ShotId); Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit"); Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)"); + Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit + } + + // The other stage of the same rule: a target gone before the shot even commits. The shot never got its + // commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this + // ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached. + [Fact] + public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss() + { + var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s + Charge(e); + var committed = new List(); + var resolved = new List(); + using var c = e.Zone.ShotCommitted.Subscribe(committed.Add); + using var r = e.Zone.ShotResolved.Subscribe(resolved.Add); + + var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + Assert.Empty(committed); + + e.Zone.Entities.Remove(e.Target); + e.Zone.Update(1f); + + foreach (var outcome in new[] { Assert.Single(committed), Assert.Single(resolved) }) + { + Assert.Equal(shotId, outcome.ShotId); + Assert.False(outcome.Hit); + Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield"); + Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield"); + } + Assert.Empty(e.Zone.PendingShots); + } + + // ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ---- + + // Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system, + // and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing. + // Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off; + // unaided Tracking (10) would halve the shot's chance. + [Fact] + public void AFittedTargetingSystemForgivesAJink() + { + var e = Build(velocity: 20); + FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + e.Zone.Update(1f); + e.Target.Position += float3(5, 0, 0); + + var shot = e.Zone.PendingShots.Single(); + var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation); + Assert.Equal(5f, deviation, 3); + Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}"); + } + + // ---- A beam's shot carries the interval's damage, not the weapon's. ---- + + // ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's + // damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots -- + // a beam doing ten times its damage at the default interval -- and survived, because the beam test + // counted its rolls and multiplied the count by the expected damage itself. + [Fact] + public void ADamageOverrideIsWhatTheShotCarries() + { + var e = Build(damage: 5, velocity: 20); + var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter); + var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f); + + Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage); + Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage); } // ---- Splash damages the side the blast came from. ---- diff --git a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs index efb66b5f2..2fbc5e52d 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut4Tests.cs @@ -171,7 +171,7 @@ public sealed class FireControlCut4Tests : IDisposable } // A beam is a sequence of discrete rolls, not a continuous truth: over one second it produces exactly - // 1/BeamResolveInterval outcomes, each for Damage*BeamResolveInterval, totalling Damage. Interval (.25s) + // 1/BeamResolveInterval outcomes (the damage each carries is FireControlCut11Tests.ADamageOverrideIsWhatTheShotCarries). Interval (.25s) // and tick (.125s) are both exact dyadic fractions so float accumulation cannot drift the count -- and the // tick is strictly smaller than the interval, so the two are distinguishable from a "one roll per tick" // mutant (which would produce 8 outcomes here, not 4) and from a "one roll per burst" mutant (1, not 4). @@ -190,7 +190,6 @@ public sealed class FireControlCut4Tests : IDisposable Assert.Equal(4, outcomes.Count); // 1s / .25s per roll Assert.All(outcomes, o => Assert.True(o.Hit)); - Assert.Equal(10f, outcomes.Count * 10f * settings.BeamResolveInterval, 2); // Damage * interval, per roll } // ---- Regression: FireControl.Fire must run whether or not a presentation is listening. Both diff --git a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs index acdeeb48d..3e981bbf3 100644 --- a/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs +++ b/tests/Aetheria.Shared.Tests/FireControlCut9Tests.cs @@ -255,7 +255,7 @@ public sealed class FireControlCut9Tests : IDisposable public Weapon Weapon; } - private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision) + private KernelEngagement BuildKernelEngagement(GameplaySettings settings, Shape hullShape, float precision, float spread = 0) { var hullData = new HullData { @@ -274,7 +274,7 @@ public sealed class FireControlCut9Tests : IDisposable Behaviors = { new InstantWeaponData { Damage = Constant(10), Range = Constant(1000), MinRange = Constant(0), - Velocity = Constant(0), Spread = Constant(0), DamageSpread = Constant(0), + Velocity = Constant(0), 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) } } } } @@ -386,60 +386,68 @@ public sealed class FireControlCut9Tests : IDisposable // Soul pass, on paths that used to bail in nanoseconds. Both now call the same factor functions. // --------------------------------------------------------------------------------------------------- - // The invariant the diagnostics refactor was protecting, made structural rather than conventional: the - // HUD can never report a probability the simulation does not use. Swept across every gate the hot path - // bails on -- no target, not visible, out of range, out of arc -- and across Precision and info levels - // for shots that pass. Mutation: change a factor in only one of the two callers (drop POnHull from - // HitProbability); the passing configurations then disagree. + // The invariant the diagnostics refactor was protecting: the HUD can never report a probability the + // simulation does not use. Inspect.PBase is HitProbability's own answer, so the gates and the product + // have one owner; what is left to defend is that the factors the HUD displays are the ones the + // simulation multiplies, and that a gate the HUD shows closed is one the simulation refuses on. Swept + // across Precision, info levels and a spread cone that does not fill the silhouette -- every factor + // strictly between 0 and 1 somewhere, so a factor dropped, doubled or divided in either caller shows. [Fact] - public void HitProbabilityMatchesInspect() + public void TheHudShowsTheFactorsTheSimulationMultiplies() { var settings = TestSettings(); var checkedConfigurations = 0; + var sawPartialSpread = false; foreach (var precision in new[] { .3f, 1.19f, 5f }) { - var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision); + // 6 cells wide at cell size 1 subtends ~3.4 degrees at 100 units, inside an 8-degree cone. + var e = BuildKernelEngagement(settings, SolidShape(6, 12), precision, spread: 8); - void Check(string label) - { - var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); - var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PBase; - Assert.True(hot == hud, $"{label}, Precision {precision}: HitProbability {hot} != Inspect.PBase {hud}"); - checkedConfigurations++; - } - - foreach (var info in new[] { 0f, .2f, .5f, 1f }) + foreach (var info in new[] { .2f, .5f, 1f }) { e.Shooter.EntityInfoGathered[e.Target] = info; - Check($"in range, info {info}"); + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + var hot = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); + Assert.True(hud.Visible && hud.InRange && hud.Locked && hud.InArc, $"info {info}: precondition, every gate open"); + Assert.True(hot > 0f, $"info {info}, Precision {precision}: precondition, a shot that can land"); + Assert.Equal(hot, hud.Accuracy * hud.PSensor * hud.PSpread * hud.POnHull, 5); + sawPartialSpread |= hud.PSpread < .99f; + checkedConfigurations++; } e.Shooter.EntityInfoGathered[e.Target] = 1f; - e.Target.Position = float3(0, 0, 5000); - Check("out of range"); - e.Target.Position = float3(0, 0, -100); - Check("behind the mount"); - e.Target.Position = float3(0, 0, 100); + void Closed(string label, Func gate) + { + var hud = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.False(gate(hud), $"{label}: the HUD must show this gate closed"); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, e.Target)); + checkedConfigurations++; + } - // Cut 11: a shooter far from the origin. Every other configuration keeps the shooter at float3.zero, - // where `target - source` and `target + source` are the same vector; here the correct separation is - // 500 (in range) while the mutated sum is 1300 (out of range), so a sign mutant in either caller's - // bearing makes the two disagree. - var home = e.Shooter.Position; + e.Target.Position = float3(0, 0, 5000); + Closed("out of range", d => d.InRange); + e.Target.Position = float3(0, 0, -100); + Closed("behind the mount", d => d.InArc); + + // A shooter far from the origin: at float3.zero `target - source` and `target + source` are the + // same vector, so a sign mutant in the bearing only shows here (500 in range vs 1300 out). e.Shooter.Position = float3(600, 0, 0); e.Target.Position = float3(600, 0, 500); - Check("shooter far from the origin"); - e.Shooter.Position = home; + var far = FireControl.Inspect(e.Weapon, e.Shooter, e.Target); + Assert.Equal(500f, far.Range, 3); + Assert.True(far.InRange && FireControl.HitProbability(e.Weapon, e.Shooter, e.Target) > 0f, "shooter far from the origin"); + checkedConfigurations++; + e.Shooter.Position = float3.zero; e.Target.Position = float3(0, 0, 100); e.Shooter.VisibleEntities.Remove(e.Target); - Check("not visible"); + Closed("not visible", d => d.Visible); - var nullHot = FireControl.HitProbability(e.Weapon, e.Shooter, null); - var nullHud = FireControl.Inspect(e.Weapon, e.Shooter, null).PBase; - Assert.True(nullHot == nullHud, $"no target: {nullHot} != {nullHud}"); + Assert.False(FireControl.Inspect(e.Weapon, e.Shooter, null).HasTarget); + Assert.Equal(0f, FireControl.HitProbability(e.Weapon, e.Shooter, null)); } - Assert.Equal(24, checkedConfigurations); // 3 precisions x (4 info levels + out of range + behind + far shooter + not visible) + Assert.True(sawPartialSpread, "fixture: the spread cone must not fill the silhouette, or PSpread is 1 and proves nothing"); + Assert.Equal(21, checkedConfigurations); // 3 precisions x (3 info levels + out of range + behind + far shooter + not visible) } // The cost regression itself. A target out of range is the common case for every AI and turret, and it