## 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 selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0, dist = 0.0, selfEnergy = 100.0, ramming = false): (GunId, int, float, PowerCap) = ## `selectShot` plus the energy-aware power-policy decision, so a caller can ## log the cap and its reason (see `applyPowerPolicy` in virtual_bullets). ## ## `dist` is the current distance (px) to the target and `selfEnergy` our own ## energy; `ramming` exempts the caps (the movement code's `shouldRam` is the ## single source of truth). The policy is applied identically wherever this is ## called, so live and any offline caller cannot diverge. let gunId = t.selectGun(targetId, tick) let (prefBin, preferred) = t.bestPower(gunId, targetId) # pEst / pRef mirror `bestPower`'s own fitness source (per-target when data # exists, else the deterministic aggregate). An empty bin carries no rate of # its own, so it borrows the gun's aggregate — the same "no data" case the # policy documents. let fit = t.fitnessFor(targetId) let pRef = if PowerRefFixed > 0.0: PowerRefFixed else: gunRate(fit[gunId], pooled = true) let pEst = if fit[gunId].bins[prefBin].count == 0: pRef else: fit[gunId].bins[prefBin].hitRate() let dec = applyPowerPolicy(preferred, dist, selfEnergy, pEst, pRef, ramming) result = (gunId, binIndexForPower(dec.power), dec.power, dec) proc selectShot*(t: var VirtualTracker, targetId = -1, tick = 0, dist = 0.0, selfEnergy = 100.0, ramming = false): (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`). `dist`/`selfEnergy`/`ramming` ## feed the energy-aware power cap (`TR_POWER_POLICY`); defaults keep every ## existing caller compiling, and `TR_POWER_POLICY=0` reproduces the uncapped ## `bestPower` preference. Use `selectShotPolicy` when the cap/reason is needed. let (gunId, binIdx, power, _) = t.selectShotPolicy(targetId, tick, dist, selfEnergy, ramming) result = (gunId, binIdx, power)