## 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_PATTERN=both (shipped default: the ONLY admitted gun) # TR_RACK_HEADON=both -> re-admit HeadOn (used to restore the old rack) # 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) # TR_RACK_LEADGAIN=both -> the per-range-band lead-gain corrector (id 16) # TR_RACK_BITBRAIN=both -> the ADE+SBC gun (id 17); also needs TR_BITBRAIN_NET=1 # # SHIPPED DEFAULT IS `onlyPattern`: Pattern (id 5) is admitted in both racks and # every other gun is `off`. This is a deliberate, measured decision, not a # pruning heuristic — the virtual-fitness selector was measured to be NEGATIVE # value at every rack size tested (full, lean8, lean6, pairPC/PK/PL) and against # 10/10 adversaries, while Pattern alone is the best single gun in general. See # docs/selector_negative_value.md. The selector MECHANISM is retained in full # (chooseFromFit, the floor/band logic, hysteresis, virtual fitness) — the rack # merely has one member by default, so re-enabling any gun is a one-line env # override with no rebuild: # # Revert to the old full rack (all guns `both`, TMPATTERN `off`): # TR_RACK_PATTERN=both TR_RACK_HEADON=both TR_RACK_LINEAR=both \ # TR_RACK_TSETLIN=both TR_RACK_CIRCULAR=both TR_RACK_GUESSFACTOR=both \ # TR_RACK_WALLBOUNCE=both TR_RACK_ACCEL=both TR_RACK_STOPSHOT=both \ # TR_RACK_DISPLACE=both TR_RACK_AVGLEAD=both TR_RACK_DECAYGF=both \ # TR_RACK_KNN=both TR_RACK_TMSELECT=both TR_RACK_LEADGAIN=both \ # TR_RACK_BITBRAIN=both TR_BITBRAIN_NET=1 ./ModularBot # # 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. const RackGunNames*: array[18, string] = [ "HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN", "WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF", "KNN", "TMSELECT", "TMPATTERN", "TMHORIZON", "LEADGAIN", "BITBRAIN"] RackEnvPrefix* = "TR_RACK_" ## LEADGAIN (id 16) is the per-range-band lead-gain corrector ## (`guns/lead_gain.nim`). It USED to be called BITBRAIN; the name now ## describes its internals (it learns a multiplier for Pattern's lead per ## range band). Its rack id is UNCHANGED so every test that asserts the id ## literals still holds. The `BITBRAIN` name and the `TR_BITBRAIN_*` prefix ## now belong to the ADE+SBC gun `guns/bitbrain_net.nim` (rack id 17). ## SHIPPED DEFAULT: `onlyPattern`. Pattern (id 5) is admitted in both racks; ## every other gun is `off`. The selection mechanism is untouched and remains ## fully functional — only the rack's membership changed. Re-enable any gun ## with `TR_RACK_`, or restore the old full rack with the one-liner in the ## header comment. TMPATTERN (id 14) stays `off`: registered and forceable but ## it never spawns a virtual bullet unless explicitly enabled, so the shared ## VirtualTracker ring head — and every other gun's learning order — is ## unchanged. DefaultRackMembership*: array[18, RackMembership] = [ rmOff, # 0 HEADON — off (measured: worst over-selected gun) rmOff, # 1 LINEAR — off rmOff, # 2 TSETLIN — off rmOff, # 3 CIRCULAR — off rmOff, # 4 GUESSFACTOR — off rmBoth, # 5 PATTERN — the only admitted gun (best single gun in general) rmOff, # 6 WALLBOUNCE — off rmOff, # 7 ACCEL — off rmOff, # 8 STOPSHOT — off rmOff, # 9 DISPLACE — off rmOff, # 10 AVGLEAD — off rmOff, # 11 DECAYGF — off rmOff, # 12 KNN — off rmOff, # 13 TMSELECT — off rmOff, # 14 TMPATTERN — off (already shipped off; TM pattern gun) rmOff, # 15 TMHORIZON — off (horizon-based TM corrector; expected to lose) rmOff, # 16 LEADGAIN — off (per-range-band lead-gain corrector) rmOff] # 17 BITBRAIN — off (the real ADE+SBC gun; also needs TR_BITBRAIN_NET=1) ## NOTE: the table is registered in the SAME commit as the gun id and the live ## wiring, so `TR_RACK_LEADGAIN=both` / `TR_RACK_BITBRAIN=both` are the ONLY ## things that admit those guns and an unset environment is byte-for-byte the ## shipped Pattern-only rack. const ## ── BACKWARD COMPATIBILITY: the legacy rack knob names ──────────────────── ## `TR_RACK_` is derived from `RackGunNames`, so renaming a gun ## silently retires its old switch. These entries keep an old switch alive: ## each maps a legacy `TR_RACK_*` name onto the gun id it used to address. ## `TR_RACK_BITBRAIN` is the ONE genuinely ambiguous legacy name — the rack is ## keyed by gun name, and the new ADE+SBC gun is now the one called ## `BITBRAIN`. It is disambiguated by the same switch the knobs use, ## `TR_BITBRAIN_NET` (default 0): ## * unset/0 -> LEGACY: `TR_RACK_BITBRAIN` selects LEADGAIN (id 16), the gun ## it always selected, and the new gun stays off (the shipped default); ## * 1 -> `TR_RACK_BITBRAIN` selects the new BITBRAIN gun (id 17). ## (The new gun additionally requires `TR_BITBRAIN_NET=1` in its own ## `predict`, so even an explicitly racked `TR_RACK_BITBRAIN=both` cannot turn ## it on while the namespace is still in legacy mode.) RackLegacyAlias*: array[1, (string, int)] = [("TR_RACK_BITBRAIN", 16)] RackLegacyAliasGunName* = "LEADGAIN" ## what the legacy name selects today RackNetGunId* = 17 ## the ADE+SBC gun that owns the name once switched proc netSwitchOwnsBitbrainName*(): bool = ## `TR_BITBRAIN_NET` (default 0) is THE disambiguation switch for the whole ## `TR_BITBRAIN_*` namespace. Unset/0 => the namespace is LEGACY and belongs ## to the renamed lead-gain corrector; 1 => it belongs to the ADE+SBC gun. ## Defined here (and identically in `guns/lead_gain.nim`) because ## `gun_harness/selector` must not depend on a concrete gun module. case getEnv("TR_BITBRAIN_NET", "").strip().toLowerAscii() of "1", "true", "yes", "on": true else: false 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. ## Legacy alias names (`RackLegacyAlias`) are applied only when the ## corresponding CURRENT name is unset, so a migrated config always wins. result = DefaultRackMembership var legacyTouched: seq[string] let netOwns = netSwitchOwnsBitbrainName() for i in 0.. 0: result[i] = parseRackMembership(v) if not netOwns: for (key, gid) in RackLegacyAlias: let current = RackEnvPrefix & RackGunNames[gid] if getEnv(key, "").len > 0 and getEnv(current, "").len == 0: result[gid] = parseRackMembership(getEnv(key, "")) legacyTouched.add key if legacyTouched.len > 0: let gid = RackLegacyAlias[0][1] stderr.writeLine("[depr] legacy rack knob " & legacyTouched.join(",") & " now names the ADE+SBC gun (BITBRAIN, rack id 17); it still selects " & RackGunNames[gid] & " (rack id " & $gid & ") until TR_BITBRAIN_NET=1. Set TR_RACK_" & RackGunNames[gid] & " to make it explicit.") 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 ## default table. 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" # ── forced-share allocator (TR_RACK_SHARE) ─────────────────────────────────── # # The shipped selector RANKS guns and lets the ranking (plus hysteresis) decide # each gun's share. `GUN_SELECTOR_FLOOR` is a FITNESS floor, so nothing # guarantees the second gun ANY share of the shots — the ~66/34 split observed # with a 2-gun rack is an OUTCOME, not a policy. `TR_RACK_SHARE` makes the share # a POLICY: when set, the live selection is a deterministic deficit-round-robin # over the named, ADMITTED guns instead of `chooseFromFit`'s ranking. # # TR_RACK_SHARE=pattern:50,bitbrain:50 # TR_RACK_SHARE=pattern:0.7,bitbrain:0.3 # # Values are RELATIVE weights (fractions or percentages — only the ratio # matters) and names are case-insensitive `RackGunNames` (the `GunNames` rack # order). The schedule holds each allocated gun for the selector dwell window # (`GUN_SELECTOR_DWELL`) so the turret converges between switches, exactly like # the shipped hysteresis; the share is therefore over dwell EPOCHS, and selected # ticks follow the weight ratio (the `gun_stats.jsonl` `selected` counts are the # liveness proof). OFF by default: an unset variable leaves the weights empty # and `selectGun` takes the unchanged ranking path byte-for-byte. const RackShareEnvVar* = "TR_RACK_SHARE" type RackShare* = object active*: bool weights*: seq[float] ## indexed by gun id; 0.0 = not in the schedule named*: seq[string] ## gun names in declared order (audit only) proc parseRackShare*(value: string, membership: openArray[RackMembership]): RackShare = ## Parse `TR_RACK_SHARE`. Empty / malformed input is never fatal: it warns on ## stderr and returns an INACTIVE share (empty weights), so a typo can only ## fall back to the shipped selector, never take the bot down. A named gun ## that the rack removes (`TR_RACK_=off`) is a loud ERROR and also leaves ## the share inactive — a forced share must only allocate among ADMITTED guns. let v = value.strip() if v.len == 0: return var weights = newSeq[float](len(RackGunNames)) var named: seq[string] var total = 0.0 for part in v.split(','): let p = part.strip() if p.len == 0: continue let ci = p.find(':') if ci <= 0 or ci == p.high: stderr.writeLine("[gun_harness] ERROR: bad " & RackShareEnvVar & " entry '" & p & "' (want GUN:weight); share disabled") return let gname = p[0..= 0; share disabled") return var gid = -1 for i in 0.. 0.0: parts.add RackGunNames[i] & "=" & $weights[i] stderr.writeLine("[gun_harness] " & RackShareEnvVar & " active: " & parts.join(" ") & " (deficit round-robin over dwell epochs)") let ActiveRackShare* = parseRackShare(getEnv(RackShareEnvVar, ""), ActiveRackMembership) ## Process-wide forced-share schedule, frozen at startup. Empty weights when ## `TR_RACK_SHARE` is unset or malformed. 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, enemyEnergy = 100.0, ramming = false, rackMode: RackMode = rm1v1, membership: openArray[RackMembership] = [], share: seq[float] = ActiveRackShare.weights ): (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, `selfEnergy` our own ## energy and `enemyEnergy` the target's remaining energy (drives the ## finishing cap); `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, share = share) 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, enemyEnergy = enemyEnergy) result = (gunId, binIndexForPower(dec.power), dec.power, dec) proc selectShot*(t: var VirtualTracker, targetId = -1, tick = 0, dist = 0.0, selfEnergy = 100.0, enemyEnergy = 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`/ ## `enemyEnergy`/`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, enemyEnergy = enemyEnergy, ramming = ramming, rackMode = rackMode, membership = membership) result = (gunId, binIdx, power)