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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user