## 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 std/os import std/strutils import gun_interface import virtual_bullets # ── rack membership (TR_RACK_*) ────────────────────────────────────────────── # # Per-gun rack membership, read ONCE at process start so a single frozen binary # can be re-racked without a rebuild — the same runtime pattern as # GUN_RACK_DISABLE. A gun's membership admits it into the 1v1 rack, the melee # rack, both, or neither: # # TR_RACK_HEADON=both (shipped default for every gun) # TR_RACK_TSETLIN=1v1 -> 1v1 rack only # TR_RACK_DISPLACE=melee -> melee rack only # TR_RACK_KNN=off -> removed from both racks # TR_RACK_TMPATTERN=off (shipped default for the new TM pattern gun) # # The mode itself is derived from SERVER truth (`getEnemyCount()`), never from # the tracker's known-enemy count, by `rackMode` in virtual_bullets — the same # transition the radar uses. Every gun except TMPATTERN defaults to `both`, so an # unset environment preserves the pre-change single-rack selection byte-for-byte; # TMPATTERN defaults to `off` so it cannot alter that selection. const RackGunNames*: array[15, string] = [ "HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN", "WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF", "KNN", "TMSELECT", "TMPATTERN"] RackEnvPrefix* = "TR_RACK_" ## Defaults are all-`both` EXCEPT the new TM pattern gun (id 14), which ships ## `off`: it is registered and forceable (`TR_RACK_TMPATTERN=both|1v1|melee`) ## but never spawns a virtual bullet unless explicitly enabled, so the shared ## VirtualTracker ring head — and therefore every other gun's learning order ## and the default selection sequence — is byte-for-byte unchanged. Defaulting ## it to `both` would let it compete for selection and change the default rack. DefaultRackMembership*: array[15, RackMembership] = [ rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmOff] proc parseRackMembership*(value: string): RackMembership = ## Parse a `TR_RACK_` value. Empty / unknown values fall back to the ## shipped `both` and warn on stderr, so a typo cannot silently move a gun and ## a bad value cannot take the bot down. case value.strip().toLowerAscii() of "", "both", "any": rmBoth of "1v1", "only1v1", "1v1only", "single", "lock": rmOnly1v1 of "melee", "onlymelee", "multi": rmOnlyMelee of "off", "none", "disabled", "disable": rmOff else: stderr.writeLine("[gun_harness] unknown " & RackEnvPrefix & "='" & value & "'; falling back to 'both' (valid: both|1v1|melee|off)") rmBoth proc loadRackMembership*(): array[len(RackGunNames), RackMembership] = ## Default table plus every `TR_RACK_` override. A proc (not inlined into ## the `let`) so the unit test can exercise env parsing in-process. result = DefaultRackMembership for i in 0.. 0: result[i] = parseRackMembership(v) let ActiveRackMembership* = loadRackMembership() ## Process-wide rack table, frozen at startup. proc rackMembershipName*(m: RackMembership): string = case m of rmBoth: "both" of rmOnly1v1: "1v1" of rmOnlyMelee: "melee" of rmOff: "off" proc rackModeName*(m: RackMode): string = case m of rm1v1: "1v1" of rmMelee: "melee" proc rackOverrides*(membership: openArray[RackMembership]): string = ## Compact `GUN:mode,GUN:mode` list of entries that differ from the shipped ## all-`both` default. Empty when the rack is at its default. for i in 0.. 0: result.add "," result.add RackGunNames[i] & ":" & rackMembershipName(membership[i]) proc rackActive*(membership: openArray[RackMembership], mode: RackMode): string = ## Comma-separated gun names admitted in `mode` (empty set prints as ## `FULL` — the graceful-degradation fallback). for i in 0.. 0: result.add "," result.add RackGunNames[i] if result.len == 0: result = "FULL" 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, rackMode: RackMode = rm1v1, membership: openArray[RackMembership] = [] ): (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. ## ## `rackMode` is the server-truth enemy-count mode (`rackMode`); `membership` ## is the process-wide `TR_RACK_*` table, passed by the live bot. An empty ## membership admits every gun (the pre-change behaviour). let gunId = t.selectGun(targetId, tick, rackMode = rackMode, membership = membership) 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, rackMode: RackMode = rm1v1, membership: openArray[RackMembership] = []): (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, rackMode = rackMode, membership = membership) result = (gunId, binIdx, power)