Files
SirRoboGarage/common_libs/gun_harness/selector.nim
T
SirStone c9825dfb0b power policy: cap power by range and energy, gate 3.0 on above-average chances
Implements the user's energy management request: "firing from more than 200px
should be a 'not good chances zone' so faster bullets and more chances to hit
matters more than single hit damage with low chances. When we are lower than 50
health, same thing. I would like to use 3.0 power only when the chances of
hitting are higher than average."

Design: a CAP on top of the existing `bestPower`, not a rewrite. `bestPower`
still answers "which bin does this gun's own data prefer"; the policy caps it:

  ramming                                       -> 3.0  (reason ram, exempt)
  dist > TR_POWER_FAR_DIST (200)                -> 1.0  (far)
  elif selfEnergy < TR_POWER_LOW_ENERGY (50)    -> 1.0  (lowEnergy)
  elif pEst <= pRef                             -> 2.0  (belowAvg)
  else                                          -> 3.0  (full)
  power = min(gunPreferredBinPower, cap)   # can only LOWER power

p=1.0 is the right "low" tier on the measured mechanics: bullet speed 20-3p so
p=1.0 gives speed 17 vs 11 at p=3.0 (55% faster = less lead error), fire
interval 10+2p so 12 ticks vs 16 (33% more shots), and drain 0.083/turn vs
0.1875 (2.25x slower). All three things the user asked for at long range.
pEst = the chosen bin's virtual rate (gun aggregate when the bin is empty);
pRef = the gun's aggregate mean unless TR_POWER_REF > 0. No-data guns are
vacuously below-average -> cap 2.0 (conservative, documented).

Control arm: TR_POWER_POLICY=0 = uncapped = today's behaviour exactly.
Knobs: TR_POWER_POLICY, TR_POWER_FAR_DIST, TR_POWER_LOW_ENERGY,
TR_POWER_FAR_CAP, TR_POWER_MID_CAP, TR_POWER_REF, TR_POWER_LOG.
TR_POWER_MID_CAP exists because the user did not specify the middle case
(close + healthy + not-above-average); 2.0 is the default, flippable to 1.0.

Seam: the cap lives in a pure `applyPowerPolicy` and is applied only in
`selectShot` (the single place real shots are chosen), so the logic is testable
without a battle. Ram is wired from `shouldRam` - the same value the movement
dispatch uses for the (0,50) band.

CORRECTION TO AN ASSUMPTION IN THE TASK: `offline_range.nim` does NOT call
`bestPower`/`selectShot` - it only replays virtual-bullet spawn/resolve across
all power bins, independent of the real shot's power. So there is no offline
power-selection path that could diverge from the live one, and the acceptance
test guards the metric, not the policy. Policy coverage therefore comes from the
new unit test.

Verification: test_power_policy 26/26 in BOTH modes (default and TR_POWER_POLICY=0
control arm); test_gun_harness 39, test_vbullet_metric 11, test_power_selection 3,
test_adaptive_radar 41, test_tfil_ring_weights 24; acceptance_offline_vs_online
12/12 VERDICT PASS (live battle). ModularBot compiles.

UNVERIFIED: the live effect on damage/survival/score. No A/B has run.
2026-09-21 23:59:56 +02:00

106 lines
5.8 KiB
Nim
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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)