7a6237ec20
The gun at rack id 16 learned a multiplier for Pattern's lead, separately
per range band. It was called BITBRAIN and shipped a TR_BITBRAIN_* prefix,
which is why the name read as a neural network it no longer contains.
guns/bitbrain_gun.nim -> guns/lead_gain.nim (rack id 16 UNCHANGED)
RackGunNames[16] BITBRAIN -> LEADGAIN
TR_BITBRAIN_* knobs -> TR_LEADGAIN_*
[bb] log line -> [lg]
BACKWARD COMPATIBILITY is mandatory: the live .env carries
TR_RACK_BITBRAIN=both, TR_BITBRAIN_GAINS, TR_BITBRAIN_MEM=decay and
TR_BITBRAIN_LOG=1, and those must keep behaving identically. The new ADE+SBC
gun (next commit) claims the BITBRAIN name and the TR_BITBRAIN_* prefix, so
the namespace is disambiguated by ONE deterministic switch, TR_BITBRAIN_NET
(default 0):
TR_BITBRAIN_NET unset/0 -> LEGACY: the 14 frozen legacy suffixes are aliases
for TR_LEADGAIN_*, and TR_RACK_BITBRAIN still
selects rack id 16. One [depr] line on stderr
names the new spelling of each honoured knob.
TR_BITBRAIN_NET = 1 -> the TR_BITBRAIN_* names belong to the new gun.
The legacy suffix set and the new gun's knob set are DISJOINT, so no name is
ever claimed twice; the new name always wins over its alias.
Parity: shipped rack is still onlyPattern, shipped movement is still strafe.
Guards unchanged: test_env_report 25, test_rack_membership 48,
test_tm_pattern_registration 20, test_lead_gain_registration 13 (was
test_bitbrain_registration), test_bitbrain 56, test_gun_harness 39,
test_tfil_commit_env 30. New: test_lead_gain_legacy 24.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
400 lines
20 KiB
Nim
400 lines
20 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)
|
||
#
|
||
# 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 ./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[17, string] = [
|
||
"HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN",
|
||
"WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF",
|
||
"KNN", "TMSELECT", "TMPATTERN", "TMHORIZON", "LEADGAIN"]
|
||
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[17, 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)
|
||
## NOTE: the table is registered in the SAME commit as the gun id (16) and the
|
||
## live wiring, so `TR_RACK_LEADGAIN=both` is the ONLY thing that admits it 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).
|
||
RackLegacyAlias*: array[1, (string, int)] = [("TR_RACK_BITBRAIN", 16)]
|
||
RackLegacyAliasGunName* = "LEADGAIN" ## what the legacy name selects today
|
||
|
||
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]
|
||
for i in 0..<len(RackGunNames):
|
||
let key = RackEnvPrefix & RackGunNames[i]
|
||
let v = getEnv(key, "")
|
||
if v.len > 0:
|
||
result[i] = parseRackMembership(v)
|
||
if not netSwitchOwnsBitbrainName():
|
||
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)
|