diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index 903bf2054..74aee4644 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -148,6 +148,7 @@ public class ActionGameManager : MonoBehaviour public float HitMarkerDuration; public SchematicDisplay SchematicDisplay; public SchematicDisplay TargetSchematicDisplay; + public TextMeshProUGUI DebugInfoText; [Header("Target Indicator")] public PlaceUIElementWorldspace TargetIndicator; @@ -183,6 +184,7 @@ public class ActionGameManager : MonoBehaviour private Dictionary _visibleFriendlyIndicators = new Dictionary(); private List _shipSubscriptions = new List(); private List _targetSubscriptions = new List(); + private readonly Dictionary _debugLastShots = new Dictionary(); private float _severeHeatstrokePhase; private bool _uiHidden; private bool _menuShown; @@ -981,6 +983,8 @@ public class ActionGameManager : MonoBehaviour foreach(var subscription in _shipSubscriptions) subscription.Dispose(); _shipSubscriptions.Clear(); + _debugLastShots.Clear(); + if (DebugInfoText != null) DebugInfoText.text = ""; } private void BindToEntity(Entity entity) @@ -1009,6 +1013,10 @@ public class ActionGameManager : MonoBehaviour GameplayUI.gameObject.SetActive(true); ShipPanel.Display(CurrentEntity, true); SchematicDisplay.ShowShip(CurrentEntity); + + _shipSubscriptions.Add(CurrentEntity.Zone.ShotResolved + .Where(outcome => outcome.Source == CurrentEntity) + .Subscribe(outcome => _debugLastShots[outcome.Weapon] = outcome)); FollowCamera.LookAt = ZoneRenderer.EntityInstances[CurrentEntity].LookAtPoint; FollowCamera.Follow = ZoneRenderer.EntityInstances[CurrentEntity].transform; @@ -1256,6 +1264,7 @@ public class ActionGameManager : MonoBehaviour } var target = CurrentEntity.Target.Value; + UpdateFireControlDebug(target); if (target != null) { var threshold = Settings.GameplaySettings.TargetDetectionInfoThreshold; @@ -1273,6 +1282,62 @@ public class ActionGameManager : MonoBehaviour } } + private void UpdateFireControlDebug(Entity target) + { + if (DebugInfoText == null) return; + + EquippedItem selectedItem = null; + Weapon selectedWeapon = null; + foreach (var item in CurrentEntity.Equipment) + { + var weapon = item.Behaviors.OfType().FirstOrDefault(x => !(x is LockWeapon)); + if (weapon == null) continue; + selectedItem = item; + selectedWeapon = weapon; + break; + } + + if (selectedWeapon == null) + { + DebugInfoText.text = "FIRE CONTROL\nNo non-lock weapon"; + return; + } + + var d = FireControl.Inspect(selectedWeapon, CurrentEntity, target); + var pendingLine = "pending: none"; + for (var i = CurrentEntity.Zone.PendingShots.Count - 1; i >= 0; i--) + { + var shot = CurrentEntity.Zone.PendingShots[i]; + if (shot.Source != CurrentEntity || shot.Weapon != selectedItem) continue; + if (shot.Committed) + pendingLine = $"shot {shot.ShotId}: committed {(shot.Outcome.Hit ? "HIT" : "MISS")}"; + 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}"; + } + break; + } + + var lastLine = _debugLastShots.TryGetValue(selectedItem, out var lastShot) + ? $"last {lastShot.ShotId}: {(lastShot.Hit ? "HIT" : "MISS")} cell {lastShot.Cell.x},{lastShot.Cell.y}" + : "last: none"; + var gates = target == null + ? "target: none" + : $"gates vis {d.Visible} range {d.InRange} arc {d.InArc} lock {d.Locked}"; + + DebugInfoText.text = + $"FIRE CONTROL - {selectedItem.Data.Name}\n" + + $"{gates}\n" + + $"range {d.Range:F0} [{d.MinRange:F0}..{d.MaxRange:F0}]\n" + + $"info {d.Info:F3}/{d.InfoDemandCeiling:F3} sensor {d.PSensor:F3}\n" + + $"accuracy {d.Accuracy:F3} spread {d.PSpread:F3} hull {d.POnHull:F3}\n" + + $"precision {d.Precision:F3} tracking {d.Tracking:F1}\n" + + $"base {d.PBase:P1}\n" + + $"{pendingLine}\n" + + lastLine; + } + private void LateUpdate() { UpdateTargetIndicators(); diff --git a/Assets/Scripts/ServerShared/FireControl.cs b/Assets/Scripts/ServerShared/FireControl.cs index 714a7c515..058dff41c 100644 --- a/Assets/Scripts/ServerShared/FireControl.cs +++ b/Assets/Scripts/ServerShared/FireControl.cs @@ -143,16 +143,33 @@ public static class FireControl // a tick apart may differ; nothing here or anywhere else caches one. public static float HitProbability(Weapon weapon, Entity source, Entity target) { - if (target == null) return 0f; - if (!source.VisibleEntities.Contains(target)) return 0f; + return Inspect(weapon, source, target).PBase; + } + + // Presentation-only inspection of the exact factors HitProbability owns. Keeping this calculation here + // prevents the debug HUD from growing a second, subtly different fire-control model. + public static FireControlDiagnostic Inspect(Weapon weapon, Entity source, Entity target) + { + var settings = source.ItemManager.GameplaySettings; + var diagnostic = new FireControlDiagnostic + { + HasTarget = target != null, + Accuracy = Accuracy(source), + Resolution = Resolution(source), + Precision = Precision(source), + Tracking = Tracking(source), + MinRange = weapon.MinRange, + MaxRange = weapon.Range + }; + if (target == null) return diagnostic; var toTarget = target.Position - source.Position; - var range = length(toTarget); - if (range < weapon.MinRange || range > weapon.Range) return 0f; - if (weapon is LockWeapon lockWeapon && !lockWeapon.IsLocked) return 0f; - if (!InArc(weapon.Item, toTarget)) return 0f; + diagnostic.Range = length(toTarget); + diagnostic.Visible = source.VisibleEntities.Contains(target); + diagnostic.InRange = diagnostic.Range >= weapon.MinRange && diagnostic.Range <= weapon.Range; + diagnostic.Locked = !(weapon is LockWeapon lockWeapon) || lockWeapon.IsLocked; + diagnostic.InArc = InArc(weapon.Item, toTarget); - var settings = source.ItemManager.GameplaySettings; var info = source.EntityInfoGathered.TryGetValue(target, out var gathered) ? gathered : 0f; // Cut 6c, 6c.1 (operator ruling 2026-09-19): Resolution stays a benefit -- higher is better -- so the // formula takes its reciprocal to derive the actual info ceiling instead of reading Resolution as @@ -160,20 +177,20 @@ public static class FireControl // complete information, the floor by construction. A higher Resolution pulls the ceiling down toward // the detection threshold, needing less info before sensor state stops limiting hits. The max guards // a zero or negative authored Resolution; no authored value should ever reach it. - var demandCeiling = settings.TargetDetectionInfoThreshold + - (1f - settings.TargetDetectionInfoThreshold) / max(Resolution(source), 1e-3f); - var pSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, demandCeiling, info)); + diagnostic.Info = info; + diagnostic.InfoDemandCeiling = settings.TargetDetectionInfoThreshold + + (1f - settings.TargetDetectionInfoThreshold) / max(diagnostic.Resolution, 1e-3f); + diagnostic.PSensor = saturate(unlerp(settings.TargetDetectionInfoThreshold, diagnostic.InfoDemandCeiling, info)); var targetHull = source.ItemManager.GetData(target.Hull) as HullData; - float pSpread; + diagnostic.PSpread = 1f; if (weapon.Spread > 0) { var halfExtent = .5f * max(targetHull.Shape.Width, targetHull.Shape.Height) * settings.SchematicCellSize; - var angularRadius = degrees(atan(halfExtent / range)); - pSpread = saturate(angularRadius / (weapon.Spread / 2f)); + var angularRadius = degrees(atan(halfExtent / diagnostic.Range)); + diagnostic.PSpread = saturate(angularRadius / (weapon.Spread / 2f)); } - else pSpread = 1f; // Cut 6d (docs/fire-control-cut.md): the dart-throw kernel's other half. pSpread above prices whether // the weapon's own barrel-dispersion cone even reaches the ship's silhouette at this range (weapon @@ -183,9 +200,20 @@ public static class FireControl // range-blind, aim-point-aware). Different inputs, different failure modes; see Cut(0's report for the // double-charging call). Aim point is source.ResolvedTargetItem, exactly what Fire freezes below -- // HitProbability and Fire read the same reveal-gated aim point, never two. - var pOnHull = HullKernel(targetHull, ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, Precision(source)).POnHull; + diagnostic.POnHull = HullKernel(targetHull, + ResolveAimPoint(target, targetHull, source.ResolvedTargetItem).AimPoint, diagnostic.Precision).POnHull; - return Accuracy(source) * pSensor * pSpread * pOnHull; + if (diagnostic.Visible && diagnostic.InRange && diagnostic.Locked && diagnostic.InArc) + diagnostic.PBase = diagnostic.Accuracy * diagnostic.PSensor * diagnostic.PSpread * diagnostic.POnHull; + return diagnostic; + } + + public static float DeviationProbability(PendingShot shot, float now, out float deviation) + { + var elapsed = now - shot.FireTime; + var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; + deviation = shot.Target == null ? 0f : length((shot.Target.Position - predicted).xz); + return shot.Target == null ? 1f : saturate(1f - deviation / shot.Tracking); } // Cut 3, R1: called once per burst step from InstantWeapon.Execute. Computes flight time, freezes the @@ -329,12 +357,9 @@ public static class FireControl var p = shot.PBase; if (p > 0f && shot.Target != null) { - var elapsed = now - shot.FireTime; - var predicted = shot.FireTargetPosition + shot.FireTargetVelocity * elapsed; - var deviation = length((shot.Target.Position - predicted).xz); + 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. - var pDeviation = saturate(1f - deviation / shot.Tracking); p *= pDeviation; } @@ -570,6 +595,28 @@ public struct PendingShot public ShotOutcome Outcome; } +public struct FireControlDiagnostic +{ + public bool HasTarget; + public bool Visible; + public bool InRange; + public bool Locked; + public bool InArc; + public float Range; + public float MinRange; + public float MaxRange; + public float Info; + public float InfoDemandCeiling; + public float Accuracy; + public float Resolution; + public float Precision; + public float Tracking; + public float PSensor; + public float PSpread; + public float POnHull; + public float PBase; +} + // 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