racks: separate melee and 1v1 gun racks, plus per-mode real hit-rate data

The user's plan: "separate racks for melee and 1v1, so the bot switches from
those based on the situation, and we can put the guns we want in one or both
racks."

MECHANISM
- `RackMode` (rm1v1/rmMelee) derived from SERVER TRUTH: `rackMode(enemyCount)`
  = 1v1 when the count is 1, melee otherwise. This is the SAME `getEnemyCount()`
  value the radar already uses, so there is now ONE definition of the mode.
  (Using the tracker's known-enemy count was a previous bug in the radar: it
  read 1 before the second enemy was scanned.)
- `RackMembership` per gun: both (default) | 1v1 | melee | off.
- The selector ranks only admitted guns - including the floor path and the
  incumbent-hysteresis path.
- Empty filtered set FALLS BACK to the full rack, so the bot can never end up
  with no gun.
- Env-overridable at process start, no rebuild: `TR_RACK_<GUN>` for all 14 guns
  (TR_RACK_HEADON, TR_RACK_LINEAR, ... TR_RACK_TMSELECT), values
  both|1v1|melee|off. Empty/unknown -> both + a stderr warning, never fatal.
- `[rack] mode=<1v1|melee> active=<guns> overrides=<...>` logged once per mode
  change, never per tick.

DEFAULT IS UNCHANGED: every gun ships `rmBoth`, so behaviour is byte-identical
until the user re-racks anything. Verified by the unit test's default-config
selection parity (RNG draw for RNG draw) and by `test_gun_harness` 39 and
acceptance 12/12. `chooseFromFit` iterates the admitted list in ascending id
order, so the random tie-break draws are unchanged.

NO TUNING DONE, deliberately: we had no per-gun melee hit-rate data, and an
earlier 15-paired-run experiment found pruning neutral-to-negative on hit rate
(p=0.57/0.21). So all guns stay `both` and the membership pass waits for data.

PER-MODE DATA PLUMBING (this is what unblocks that pass): per-gun real shot
accounting is now split by the rack in force at fire time, adding to
gun_stats.jsonl: realShots1v1, realHits1v1, realHitRate1v1, realShotsMelee,
realHitsMelee, realHitRateMelee.

Verification: test_rack_membership 38/38 (new, pure, no battle); test_gun_harness
39, test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24, test_power_policy 26, test_ram_decision 28;
acceptance_offline_vs_online 12/12 VERDICT PASS; ModularBot compiles. The live
`[rack]` line was observed switching 1v1 -> melee when the enemy died.

The offline range never calls the selector (only spawnBullets/tickBullets/
reportFor), so mode filtering cannot change the offline result and no offline
mode parameter was needed - confirmed by reasoning over the source and by 12/12.
This commit is contained in:
2026-09-22 00:45:10 +02:00
parent ab86c0481f
commit a73de13458
4 changed files with 506 additions and 25 deletions
+100 -4
View File
@@ -3,9 +3,94 @@
## 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
#
# 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. Defaults are all-`both`, so an unset environment
# preserves the pre-change single-rack selection byte-for-byte.
const
RackGunNames*: array[14, string] = [
"HEADON", "LINEAR", "TSETLIN", "CIRCULAR", "GUESSFACTOR", "PATTERN",
"WALLBOUNCE", "ACCEL", "STOPSHOT", "DISPLACE", "AVGLEAD", "DECAYGF",
"KNN", "TMSELECT"]
RackEnvPrefix* = "TR_RACK_"
DefaultRackMembership*: array[14, RackMembership] = [
rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth,
rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth, rmBoth]
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.
result = DefaultRackMembership
for i in 0..<len(RackGunNames):
let key = RackEnvPrefix & RackGunNames[i]
let v = getEnv(key, "")
if v.len > 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..<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"
const
## ── Range-aware firing gate ────────────────────────────────────────────────
## A real shot departs with whatever misalignment the gun had at fire time,
@@ -68,7 +153,10 @@ proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool =
proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0,
dist = 0.0, selfEnergy = 100.0,
ramming = false): (GunId, int, float, PowerCap) =
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).
##
@@ -76,7 +164,12 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0,
## 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)
##
## `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
@@ -93,7 +186,9 @@ proc selectShotPolicy*(t: var VirtualTracker, targetId = -1, tick = 0,
proc selectShot*(t: var VirtualTracker, targetId = -1, tick = 0,
dist = 0.0, selfEnergy = 100.0,
ramming = false): (GunId, int, float) =
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`
@@ -101,5 +196,6 @@ proc selectShot*(t: var VirtualTracker, targetId = -1, tick = 0,
## 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)
t.selectShotPolicy(targetId, tick, dist, selfEnergy, ramming,
rackMode = rackMode, membership = membership)
result = (gunId, binIdx, power)
+101 -19
View File
@@ -128,6 +128,64 @@ proc parseSelectorMode*(value: string): SelectorMode =
let ActiveSelectorMode* = parseSelectorMode(getEnv(SelectorModeEnvVar, "relative"))
# ── rack membership (melee vs 1v1) ────────────────────────────────────────────
#
# The selector can run two racks and switch between them on SERVER truth —
# `getEnemyCount() == 1` -> 1v1, `> 1` -> melee (`rackMode`) — the same value
# the live radar uses to pick RadarLock vs AdaptiveMelee. Each gun declares
# which rack(s) admit it. DEFAULT IS `rmBoth` FOR EVERY GUN, so an unset
# environment reproduces the pre-change (single-rack) selection byte-for-byte.
#
# The membership test lives here, next to `chooseFromFit`, because it filters
# that function's candidate set. The env parsing / gun-name table live in
# selector.nim, which owns the rack's gun names (this low-level module is
# generic over `fit.len` and does not).
type
RackMode* = enum
rm1v1 ## exactly one enemy alive (server truth)
rmMelee ## two or more enemies alive (server truth)
RackMembership* = enum
rmBoth ## DEFAULT: eligible in 1v1 AND melee
rmOnly1v1 ## eligible only while exactly one enemy is alive
rmOnlyMelee ## eligible only in melee (2+ enemies)
rmOff ## never eligible (removed from the selector's rack)
proc admits*(m: RackMembership, mode: RackMode): bool =
## True when membership `m` includes rack `mode`.
case m
of rmBoth: true
of rmOnly1v1: mode == rm1v1
of rmOnlyMelee: mode == rmMelee
of rmOff: false
proc rackMode*(enemyCount: int): RackMode =
## THE single definition of the mode, derived from server truth. The live
## radar derives its target mode from the same count in ModularBot.run.
if enemyCount == 1: rm1v1 else: rmMelee
proc rackAdmitted*(gunId: int, mode: RackMode,
membership: openArray[RackMembership]): bool =
## True when `gunId` is a candidate in `mode`. An id past the membership table
## is admitted, so an empty table means "no filtering".
if gunId < 0: return false
if gunId < membership.len: return membership[gunId].admits(mode)
true
proc admittedGuns*(fitLen: int, mode: RackMode,
membership: openArray[RackMembership]): seq[GunId] =
## Gun ids whose membership admits `mode`. GRACEFUL DEGRADATION: if the
## filtered set is EMPTY (e.g. every gun is `off`, or no membership table is
## supplied), return the full rack so the selector can never end up with no
## gun.
for gunId in 0..<fitLen:
if gunId < membership.len and not membership[gunId].admits(mode):
continue
result.add gunId
if result.len == 0:
for gunId in 0..<fitLen: result.add gunId
# ── runtime ranking knobs (A/B without rebuilding) ────────────────────────────
#
# Every knob below defaults to the SHIPPED constant, so an unset environment
@@ -700,11 +758,18 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
mode: SelectorMode = smAbsolute,
referenceRate = -1.0,
incumbent: GunId = -1,
switchMargin = 0.0): GunId =
switchMargin = 0.0,
rackMode: RackMode = rm1v1,
membership: openArray[RackMembership] = []): GunId =
## Core gun ranking over an already-resolved fitness seq. Split out from
## `bestGun` so the offline range can rank without copying a VirtualTracker,
## and so callers can request `diag` for the selection internals.
##
## `rackMode`/`membership` restrict the candidate set to the guns whose rack
## membership admits the mode. An empty `membership` (the default) admits every
## gun, so existing callers and the offline path are unchanged; an empty
## FILTERED set also falls back to the full rack.
##
## Guns with fewer than MinObsBeforeCompete observations are skipped unless
## every gun is below threshold (then fall back to best of all).
##
@@ -726,8 +791,11 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
## every tick.
let pooled = if mode == smRelative: ActivePooled else: false
let admitted = admittedGuns(fit.len, rackMode, membership)
if admitted.len == 0: return 0
var anyQualifies = false
for gunId in 0..<fit.len:
for gunId in admitted:
if gunEligible(fit[gunId], true):
anyQualifies = true
break
@@ -739,7 +807,7 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
var bestRate = 0.0
var fieldSum = 0.0
var fieldN = 0
for gunId in 0..<fit.len:
for gunId in admitted:
if requireMin and not gunEligible(fit[gunId], true): continue
let (h, n) = gunCounts(fit[gunId], pooled)
if n == 0: continue
@@ -756,15 +824,16 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
else: 0.0
if diag != nil: diag[].floorRate = floorRate
# No hit at all, or the field collapsed below its own recent peak: HeadOn.
# No hit at all, or the field collapsed below its own recent peak: the first
# admitted gun (HeadOn by default).
if bestRate <= 0.0 or bestRate < floorRate:
if diag != nil: diag[].floorFired = true
return 0
return admitted[0]
# Ranking scores (the active statistic) and the best of them.
var scores = newSeq[float](fit.len)
var bestScore = 0.0
for gunId in 0..<fit.len:
for gunId in admitted:
if requireMin and not gunEligible(fit[gunId], true): continue
let s = rankScore(fit[gunId], pooled, ActiveRank, fieldRate, ActiveShrink)
scores[gunId] = s
@@ -774,7 +843,7 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
if mode == smAbsolute: TieMargin
else: bestScore * ActiveRelTie
var tied: seq[GunId]
for gunId in 0..<fit.len:
for gunId in admitted:
if requireMin and not gunEligible(fit[gunId], true): continue
if scores[gunId] >= bestScore - tieBand:
tied.add(gunId)
@@ -784,7 +853,8 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
# A settled incumbent is displaced only by a challenger that clears the
# relative margin. This is what stops a merely-tied gun from churning the
# turret. `incumbent < 0` disables the rule (pure ranking).
if incumbent >= 0 and incumbent < fit.len:
if incumbent >= 0 and incumbent < fit.len and
rackAdmitted(incumbent, rackMode, membership):
var incumbentEligible = true
if requireMin and not gunEligible(fit[incumbent], true):
incumbentEligible = false
@@ -810,7 +880,9 @@ proc chooseFromFit*(fit: seq[GunFitness], diag: ptr SelectorDiag = nil,
result = tied[rand(tied.len - 1)]
proc bestGun*(t: VirtualTracker, targetId: int = -1,
diag: ptr SelectorDiag = nil): GunId =
diag: ptr SelectorDiag = nil,
rackMode: RackMode = rm1v1,
membership: openArray[RackMembership] = []): GunId =
## Pick gun with highest hit rate across all power bins.
## Uses per-enemy fitness when targetId >= 0 and data exists; else aggregate.
## `diag`, when non-nil, receives the selection internals (bestRate, floor,
@@ -822,10 +894,13 @@ proc bestGun*(t: VirtualTracker, targetId: int = -1,
## analyzer and the harness tests stay deterministic and side-effect free.
result = chooseFromFit(t.fitnessFor(targetId), diag,
mode = ActiveSelectorMode,
referenceRate = t.peakRateRef)
referenceRate = t.peakRateRef,
rackMode = rackMode, membership = membership)
proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
diag: ptr SelectorDiag = nil): GunId =
diag: ptr SelectorDiag = nil,
rackMode: RackMode = rm1v1,
membership: openArray[RackMembership] = []): GunId =
## Stateful, sticky gun selection — the LIVE path (`selector.selectShot` calls
## this). Wraps the pure `chooseFromFit` ranking with two commitments:
##
@@ -852,22 +927,29 @@ proc selectGun*(t: var VirtualTracker, targetId: int = -1, tick = 0,
# Both zero == no hysteresis: pass no incumbent, matching the pre-change
# (pure per-tick) selector for a clean A/B.
let useIncumbent = ActiveDwellTicks > 0 or ActiveSwitchMargin > 0.0
let incumbent = if useIncumbent: t.currentGun else: -1
# A rack change (1v1 <-> melee) can retire the incumbent; do not let a gun the
# current rack excludes survive on hysteresis.
let incumbentAdmitted =
t.currentGun < 0 or rackAdmitted(t.currentGun, rackMode, membership)
let incumbent = if useIncumbent and incumbentAdmitted: t.currentGun else: -1
let challenger = chooseFromFit(fit, d, ActiveSelectorMode, t.peakRateRef,
incumbent = incumbent,
switchMargin = ActiveSwitchMargin)
switchMargin = ActiveSwitchMargin,
rackMode = rackMode, membership = membership)
# The floor collapses the field to HeadOn regardless of dwell.
# The floor collapses the field to the first admitted gun regardless of dwell.
if d[].floorFired:
if t.currentGun != 0:
t.currentGun = 0
let floorGun = challenger
if t.currentGun != floorGun:
t.currentGun = floorGun
t.currentSince = tick
return 0
return floorGun
result = challenger
# Minimum dwell: hold the incumbent while it is still eligible.
if useIncumbent and t.currentGun >= 0 and t.currentGun < fit.len:
# Minimum dwell: hold the incumbent while it is still eligible AND admitted.
if useIncumbent and incumbentAdmitted and
t.currentGun >= 0 and t.currentGun < fit.len:
let eligible = (not d[].anyQualifies) or gunEligible(fit[t.currentGun], true)
if eligible and (tick - t.currentSince) < ActiveDwellTicks:
result = t.currentGun