59c5af0499
TR_RACK_BITBRAIN=both is what the owner's live .env carries, and with the new ADE+SBC gun registered at id 17 under the SAME rack name that value was also landing on id 17 - so a gun that was not enabled (TR_BITBRAIN_NET unset) was admitted into the rack and its placeholder predictions were pushed into the shared VirtualTracker ring, which shifts every other gun's learning order. While the namespace is LEGACY, loadRackMembership now skips id 17's TR_RACK_BITBRAIN entirely, so that value addresses ONLY the gun it always addressed (LEADGAIN, id 16). ModularBot additionally gates admission on BitbrainNetGun.gunAdmitted(), and test_bitbrain_net pins the truth table: over 6 (rack, switch) settings there is NO configuration that admits the gun while leaving it disabled. Guards: test_env_report 25, test_rack_membership 49 (was 48; the revert one-liner now sets TR_BITBRAIN_NET=1 and one truth-table check was added), test_tm_pattern_registration 20, test_bitbrain 56, test_gun_harness 39, test_tfil_commit_env 30, test_lead_gain_registration 13, test_lead_gain_legacy 24, test_bitbrain_net 44. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
414 lines
21 KiB
Nim
414 lines
21 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 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_<GUN>`, 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_<gun name>` 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_<GUN>` 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 & "<GUN>='" & value &
|
||
"'; falling back to 'both' (valid: both|1v1|melee|off)")
|
||
rmBoth
|
||
|
||
proc loadRackMembership*(): array[len(RackGunNames), RackMembership] =
|
||
## Default table plus every `TR_RACK_<GUN>` 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..<len(RackGunNames):
|
||
let key = RackEnvPrefix & RackGunNames[i]
|
||
# While the namespace is LEGACY, `TR_RACK_BITBRAIN` must address ONLY the
|
||
# gun it always addressed (LEADGAIN, id 16) — never id 17, or a pre-rename
|
||
# `.env` would silently admit a gun that did not exist then.
|
||
if not netOwns and i == RackNetGunId: continue
|
||
let v = getEnv(key, "")
|
||
if v.len > 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..<min(len(RackGunNames), membership.len):
|
||
if membership[i] != rmBoth:
|
||
if result.len > 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..<min(len(RackGunNames), membership.len):
|
||
if membership[i].admits(mode):
|
||
if result.len > 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_<GUN>=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..<ci].strip()
|
||
var wstr = p[ci+1..^1].strip()
|
||
if wstr.endsWith("%"): wstr = wstr[0..^2].strip()
|
||
var w: float
|
||
try: w = parseFloat(wstr)
|
||
except ValueError:
|
||
stderr.writeLine("[gun_harness] ERROR: bad " & RackShareEnvVar &
|
||
" weight '" & wstr & "' for '" & gname &
|
||
"'; share disabled")
|
||
return
|
||
if w < 0.0 or w != w:
|
||
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar &
|
||
" weight for '" & gname & "' must be >= 0; share disabled")
|
||
return
|
||
var gid = -1
|
||
for i in 0..<len(RackGunNames):
|
||
if RackGunNames[i].toLowerAscii() == gname.toLowerAscii():
|
||
gid = i
|
||
break
|
||
if gid < 0:
|
||
stderr.writeLine("[gun_harness] ERROR: unknown gun '" & gname & "' in " &
|
||
RackShareEnvVar & " (valid: " &
|
||
RackGunNames.join("|") & "); share disabled")
|
||
return
|
||
if gid < membership.len and membership[gid] == rmOff:
|
||
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar & " names '" &
|
||
RackGunNames[gid] & "' but TR_RACK_" & RackGunNames[gid] &
|
||
"=off — a forced share allocates only among ADMITTED " &
|
||
"guns; share disabled")
|
||
return
|
||
weights[gid] += w
|
||
named.add RackGunNames[gid]
|
||
total += w
|
||
if total <= 0.0:
|
||
stderr.writeLine("[gun_harness] ERROR: " & RackShareEnvVar &
|
||
" weights sum to <= 0; share disabled")
|
||
return
|
||
for i in 0..<weights.len: weights[i] /= total
|
||
result = RackShare(active: true, weights: weights, named: named)
|
||
var parts: seq[string]
|
||
for i in 0..<weights.len:
|
||
if weights[i] > 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)
|