Add live fire control diagnostics

This commit is contained in:
Meta Vens 2026-09-20 21:58:44 +02:00
commit 72c0109cf1
2 changed files with 132 additions and 20 deletions

View file

@ -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<Entity, VisibleTargetIndicator> _visibleFriendlyIndicators = new Dictionary<Entity, VisibleTargetIndicator>();
private List<IDisposable> _shipSubscriptions = new List<IDisposable>();
private List<IDisposable> _targetSubscriptions = new List<IDisposable>();
private readonly Dictionary<EquippedItem, ShotOutcome> _debugLastShots = new Dictionary<EquippedItem, ShotOutcome>();
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<Weapon>().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();

View file

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