c9825dfb0b
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.
106 lines
5.8 KiB
Nim
106 lines
5.8 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 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)
|