18f778056b
Hypothesis under test: the selector chatters (~54 switches/100 ticks) and that chatter suppresses firing, so committing to the virtual-best gun should raise real hit rate. MEASURED AGAINST THE REAL DRUSSGT: it does not. setting switches/100t real hit % dmg/run shots/run no hysteresis 0/0 54.29 7.02% 217 244.8 light 10/0.05 1.95 6.22% 191 235.3 moderate 30/0.15 1.33 5.10% 156 233.9 aggressive 60/0.30 - 5.72% 175 242.5 (16 runs x 7 rounds per config except aggressive = 8; server-side events sidecar; permutation test baseline-vs-moderate p=0.002, baseline-vs-light p=0.18.) Hysteresis cuts chatter 28-54x but every variant fires slightly FEWER shots and deals LESS damage than baseline. Mechanism [INFERRED, consistent with docs/gun_rack_analysis.md 2/4]: the per-tick random tie-break among the tied band is a hedge, and hysteresis destroys it by committing to the virtual-best gun - which is not the real-best, because the virtual metric is a weak, sign-unstable ranker. The chattering was load-bearing. Shipped: GunDwellTicks=10, GunSwitchMargin=0.05 (GUN_SELECTOR_DWELL / GUN_SELECTOR_MARGIN) - the only setting within the baseline's run-to-run spread. GUN_SELECTOR_DWELL=0 GUN_SELECTOR_MARGIN=0 reproduces the pre-change selector exactly. Seam: VirtualTracker, which already owns the other selection state (fitness, the relative floor's peakRateRef), so the bot needs no new fields. bestGun and chooseFromFit stay pure/memoryless, which is why the existing random-tiebreak test needed no change. Guards: test_gun_harness 39/39 (33 original + 6 new hysteresis checks), test_vbullet_metric, test_power_selection, acceptance_offline_vs_online 12/12.
76 lines
4.1 KiB
Nim
76 lines
4.1 KiB
Nim
## Gun selector — picks best gun×power, computes aim angle, gates firing.
|
||
## Fires highest power with acceptable hit rate when the gun is aimed within a
|
||
## range-dependent angular tolerance and gunHeat == 0.
|
||
|
||
import std/math
|
||
import gun_interface
|
||
import virtual_bullets
|
||
|
||
const
|
||
## ── Range-aware firing gate ────────────────────────────────────────────────
|
||
## A real shot departs with whatever misalignment the gun had at fire time,
|
||
## while a virtual bullet is spawned exactly on the prediction and carries zero
|
||
## aim error. At distance `d` the target subtends an angular half-width of
|
||
## `atan(BotRadius / d)`, so a fixed degree threshold is simultaneously too
|
||
## loose at long range (throws away shots that cannot hit) and too tight up
|
||
## close (holds fire when the bot is already inside the hit cone).
|
||
##
|
||
## We therefore derive the tolerance from the target's angular radius:
|
||
##
|
||
## tolDeg = radToDeg(arctan(BotRadius * SafetyFactor / distPx))
|
||
##
|
||
## clamped to [MinAimThresholdDeg, MaxAimThresholdDeg].
|
||
##
|
||
## SafetyFactor shrinks/expands the accepted cone: 1.0 == the full geometric
|
||
## half-width, < 1.0 is stricter. Fitted empirically from real-shot data
|
||
## (Task A, 2611 real shots behind a wide-open 20 deg measurement gate).
|
||
## The geometric model is only weakly identified: prediction error dominates
|
||
## the hit rate, and the measured 50%-hit knee is noisy (0.9-1.4x the
|
||
## geometric cone at 200-800 px; the 400-600 px bucket is ill-defined because
|
||
## its baseline hit rate is already ~50%). Simulating the gate directly on the
|
||
## measurement data showed 0.6 Pareto-dominates the old fixed 2.0 deg gate
|
||
## (61.4% vs 60.2% hit rate with MORE shots), and the live sweep confirms the
|
||
## observed preference for tighter gates. 0.6 is the shipped compromise:
|
||
## tighter than the raw geometry while still loosening close range.
|
||
SafetyFactor* = 0.6
|
||
## Floor: keeps the tolerance strictly positive so a perfectly aligned gun can
|
||
## always fire at any range, and guards the gate against collapsing to 0
|
||
## (a never-fire deadlock) at extreme distances.
|
||
MinAimThresholdDeg* = 0.05
|
||
## Ceiling: at point-blank range the geometric cone grows without bound; a
|
||
## >10 deg misalignment is a coin toss even at ~100 px, so cap it here.
|
||
MaxAimThresholdDeg* = 10.0
|
||
|
||
proc aimToleranceDeg*(distPx: float): float =
|
||
## Angular half-width (deg) the gun may be off by and still plausibly hit a
|
||
## target `distPx` px away, scaled by SafetyFactor and clamped.
|
||
##
|
||
## Degenerate distances (0 or unavailable) fall back to the ceiling rather than
|
||
## dividing by zero; NaN is treated the same way (the `not (distPx > 0.0)`
|
||
## test is false for NaN). +Inf falls through to arctan(0) == 0 and then the
|
||
## floor, which is correct: an infinitely distant target is a point.
|
||
if not (distPx > 0.0): return MaxAimThresholdDeg
|
||
result = radToDeg(arctan(BotRadius * SafetyFactor / distPx))
|
||
if result < MinAimThresholdDeg: result = MinAimThresholdDeg
|
||
elif result > MaxAimThresholdDeg: result = MaxAimThresholdDeg
|
||
|
||
proc aimAngle*(selfX, selfY, targetX, targetY: float): float =
|
||
## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY).
|
||
result = radToDeg(arctan2(targetY - selfY, targetX - selfX))
|
||
|
||
proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool =
|
||
## Returns true when the gun is within the range-aware angular tolerance and
|
||
## cool enough to fire. `distPx` is the distance (px) to the aim point.
|
||
var delta = (targetAngle - currentGunDir) mod 360.0
|
||
if delta > 180.0: delta -= 360.0
|
||
elif delta < -180.0: delta += 360.0
|
||
abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.0
|
||
|
||
proc selectShot*(t: var VirtualTracker, targetId: int = -1, tick = 0): (GunId, int, float) =
|
||
## Returns (gunId, powerBinIdx, power) — the shot to take this tick.
|
||
## Pass targetId to pick the best gun for that specific enemy. `tick` drives
|
||
## the minimum-dwell hysteresis (see `selectGun`).
|
||
let gunId = t.selectGun(targetId, tick)
|
||
let (binIdx, power) = t.bestPower(gunId, targetId)
|
||
result = (gunId, binIdx, power)
|