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:
@@ -127,6 +127,7 @@ type
|
||||
power: float
|
||||
tick: int
|
||||
targetId: int
|
||||
mode: vb.RackMode ## rack in force at fire time (per-mode hit stats)
|
||||
|
||||
ModularBot = ref object of Bot
|
||||
hasContact: bool
|
||||
@@ -180,8 +181,13 @@ type
|
||||
realHits: int
|
||||
gunRealShots: array[14, int]
|
||||
gunRealHits: array[14, int]
|
||||
# Same real-shot accounting split by the rack in force at fire time, so a
|
||||
# later data-driven pass can rank guns per mode (1v1 vs melee).
|
||||
gunRealShotsByMode: array[vb.RackMode, array[14, int]]
|
||||
gunRealHitsByMode: array[vb.RackMode, array[14, int]]
|
||||
pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp
|
||||
bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution
|
||||
bulletMode: Table[int, vb.RackMode] ## bulletId -> rack at fire time
|
||||
bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log)
|
||||
pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp
|
||||
gunSelectionCount: array[14, int]
|
||||
@@ -196,6 +202,11 @@ type
|
||||
radarAcquireTicks: int
|
||||
radarTrackTicks: int
|
||||
radarMeleeActive: bool
|
||||
# Rack (melee vs 1v1) state. `rackMode` is derived from SERVER truth once
|
||||
# per tick in run(); `lastRackLogKey` is the change detector for the
|
||||
# once-per-change `[rack]` log line.
|
||||
rackMode: vb.RackMode
|
||||
lastRackLogKey: string
|
||||
|
||||
proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) =
|
||||
## Task A: append one JSON line per resolved real shot. The write is fully
|
||||
@@ -234,7 +245,9 @@ proc registerOwnBullet(bot: ModularBot, bulletId: int) =
|
||||
let shot = bot.pendingFires[0]
|
||||
delete(bot.pendingFires, 0)
|
||||
bot.bulletGun[bulletId] = shot.gunId
|
||||
bot.bulletMode[bulletId] = shot.mode
|
||||
inc bot.gunRealShots[shot.gunId]
|
||||
inc bot.gunRealShotsByMode[shot.mode][shot.gunId]
|
||||
when ShotLog:
|
||||
bot.bulletShot[bulletId] = shot
|
||||
# Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and
|
||||
@@ -242,8 +255,10 @@ proc registerOwnBullet(bot: ModularBot, bulletId: int) =
|
||||
# If so, credit the hit now and drop the (already resolved) map entry.
|
||||
if bulletId in bot.pendingHitBullets:
|
||||
inc bot.gunRealHits[shot.gunId]
|
||||
inc bot.gunRealHitsByMode[shot.mode][shot.gunId]
|
||||
bot.pendingHitBullets.excl(bulletId)
|
||||
bot.bulletGun.del(bulletId)
|
||||
bot.bulletMode.del(bulletId)
|
||||
bot.resolveShotLog(bulletId, true)
|
||||
|
||||
proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
|
||||
@@ -252,6 +267,7 @@ proc resolveOwnBullet(bot: ModularBot, bulletId: int): int =
|
||||
result = bot.bulletGun.getOrDefault(bulletId, -1)
|
||||
if result >= 0:
|
||||
bot.bulletGun.del(bulletId)
|
||||
bot.bulletMode.del(bulletId)
|
||||
|
||||
proc printConfig(bot: ModularBot, forceAll: bool = false) =
|
||||
## One line describing the config in use for the tick being reported. Only the
|
||||
@@ -280,6 +296,19 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) =
|
||||
bot.prevRadar = bot.radarMode
|
||||
bot.prevTarget = bot.currentTargetId
|
||||
|
||||
proc logRackChange(bot: ModularBot, force = false) =
|
||||
## Emit ONE `[rack]` line per mode / membership change (never per tick),
|
||||
## behind the existing config-log mechanism. The membership table is frozen at
|
||||
## process start, so in practice this fires on the 1v1 <-> melee transition;
|
||||
## `force` also prints the round-start state.
|
||||
let over = rackOverrides(ActiveRackMembership)
|
||||
let key = rackModeName(bot.rackMode) & "|" & over
|
||||
if not force and key == bot.lastRackLogKey: return
|
||||
bot.lastRackLogKey = key
|
||||
echo "[rack] mode=" & rackModeName(bot.rackMode) &
|
||||
" active=" & rackActive(ActiveRackMembership, bot.rackMode) &
|
||||
(if over.len > 0: " overrides=" & over else: " overrides=none")
|
||||
|
||||
proc startWorldStateRecord(bot: ModularBot) =
|
||||
## Truncate the fixture and write the meta line at round start.
|
||||
if RecordWorldState:
|
||||
@@ -483,10 +512,13 @@ method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
|
||||
|
||||
method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
|
||||
inc bot.realHits
|
||||
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
|
||||
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
|
||||
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
|
||||
let gunId = bot.resolveOwnBullet(e.bullet.bulletId)
|
||||
if gunId >= 0:
|
||||
inc bot.gunRealHits[gunId]
|
||||
inc bot.gunRealHitsByMode[hitMode][gunId]
|
||||
bot.resolveShotLog(e.bullet.bulletId, true)
|
||||
else:
|
||||
bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired
|
||||
@@ -535,6 +567,14 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
let rShots = bot.gunRealShots[gid]
|
||||
let rHits = bot.gunRealHits[gid]
|
||||
let rRate = if rShots > 0: rHits.float * 100.0 / rShots.float else: 0.0
|
||||
# Real hit rate split by the rack in force when each shot was fired, so a
|
||||
# later data-driven pass can rank guns per mode without a rebuild.
|
||||
let rShots1 = bot.gunRealShotsByMode[vb.rm1v1][gid]
|
||||
let rHits1 = bot.gunRealHitsByMode[vb.rm1v1][gid]
|
||||
let rRate1 = if rShots1 > 0: rHits1.float * 100.0 / rShots1.float else: 0.0
|
||||
let rShotsM = bot.gunRealShotsByMode[vb.rmMelee][gid]
|
||||
let rHitsM = bot.gunRealHitsByMode[vb.rmMelee][gid]
|
||||
let rRateM = if rShotsM > 0: rHitsM.float * 100.0 / rShotsM.float else: 0.0
|
||||
gunsArr.add(%*{
|
||||
"id": gid,
|
||||
"name": GunNames[gid],
|
||||
@@ -544,7 +584,13 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
"selected": bot.gunSelectionCount[gid],
|
||||
"realShots": rShots,
|
||||
"realHits": rHits,
|
||||
"realHitRate": rRate.round(1)
|
||||
"realHitRate": rRate.round(1),
|
||||
"realShots1v1": rShots1,
|
||||
"realHits1v1": rHits1,
|
||||
"realHitRate1v1": rRate1.round(1),
|
||||
"realShotsMelee": rShotsM,
|
||||
"realHitsMelee": rHitsM,
|
||||
"realHitRateMelee": rRateM.round(1)
|
||||
})
|
||||
|
||||
let row = %*{
|
||||
@@ -609,6 +655,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.lastKnownTargetId = -1
|
||||
bot.pendingFires.setLen(0)
|
||||
bot.bulletGun.clear()
|
||||
bot.bulletMode.clear()
|
||||
bot.bulletShot.clear()
|
||||
bot.pendingHitBullets.clear()
|
||||
if RadarScanLog:
|
||||
@@ -624,6 +671,10 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.gunSelectionCount[i] = 0
|
||||
bot.gunRealShots[i] = 0
|
||||
bot.gunRealHits[i] = 0
|
||||
for m in vb.RackMode:
|
||||
for i in 0..<14:
|
||||
bot.gunRealShotsByMode[m][i] = 0
|
||||
bot.gunRealHitsByMode[m][i] = 0
|
||||
# Reset per-round integrity counters so each /tmp/gun_stats.jsonl line reports
|
||||
# that round's numbers (summing the lines gives the session total).
|
||||
bot.tracker.droppedBullets = 0
|
||||
@@ -673,6 +724,13 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
setScanColor("#4D3D0D")
|
||||
bot.cfgDirty = false
|
||||
bot.printConfig(forceAll = true)
|
||||
# Rack mode from server truth (same transition the radar uses), logged once
|
||||
# per round start and again on every 1v1 <-> melee change. At round start
|
||||
# `bot.enemyCount` (from the game setup) is reliable where a raw
|
||||
# getEnemyCount() call may not be yet, and run() refreshes it every tick.
|
||||
bot.rackMode = vb.rackMode(bot.enemyCount)
|
||||
bot.lastRackLogKey = ""
|
||||
bot.logRackChange(force = true)
|
||||
# tracker fitness persists across rounds (rolling window carries over)
|
||||
|
||||
method onBotDeath*(bot: ModularBot, e: BotDeathEvent) =
|
||||
@@ -878,6 +936,11 @@ method run*(bot: ModularBot) =
|
||||
# not), so melee mode persists until only one enemy is actually left.
|
||||
let liveEnemyCount = getEnemyCount()
|
||||
let targetMode = if liveEnemyCount == 1: 0 else: 1
|
||||
# ONE definition of the rack mode: the SAME server-truth count the radar
|
||||
# uses above (`vb.rackMode` maps it to 1v1/melee). The tracker's known-enemy
|
||||
# count must not be used — see the radar note. Logged once on change.
|
||||
bot.rackMode = vb.rackMode(liveEnemyCount)
|
||||
bot.logRackChange()
|
||||
if targetMode != bot.radarMode:
|
||||
bot.radarMode = targetMode
|
||||
if bot.radarMode == 0:
|
||||
@@ -992,7 +1055,8 @@ method run*(bot: ModularBot) =
|
||||
# energy; `shouldRam` (the movement code's decision) exempts it.
|
||||
let (selectedGun, _, power, pdec) = selectShotPolicy(
|
||||
bot.tracker, tid, bot.tick,
|
||||
dist = ramDist, selfEnergy = ws.selfEnergy, ramming = shouldRam)
|
||||
dist = ramDist, selfEnergy = ws.selfEnergy, ramming = shouldRam,
|
||||
rackMode = bot.rackMode, membership = ActiveRackMembership)
|
||||
if PowerLog:
|
||||
let pkey = fmt"{power:.1f}|{pdec.cap:.1f}|{pdec.reason}"
|
||||
if pkey != bot.lastPowerLogKey:
|
||||
@@ -1060,6 +1124,7 @@ method run*(bot: ModularBot) =
|
||||
power: power,
|
||||
tick: bot.tick,
|
||||
targetId: tid,
|
||||
mode: bot.rackMode,
|
||||
))
|
||||
|
||||
setGunTurnRate(normDelta)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
## Pure unit guard for the melee/1v1 rack-membership mechanism.
|
||||
##
|
||||
## No Java, no battle, no virtual bullets: the fitness windows are seeded
|
||||
## directly so the selection is deterministic and fast. Covers:
|
||||
## * mode filtering selects the right gun subset,
|
||||
## * an EMPTY filtered set falls back to the full rack,
|
||||
## * `TR_RACK_<GUN>` env overrides parse correctly (both / 1v1 / melee / off),
|
||||
## * the shipped defaults are all-`both`,
|
||||
## * the default configuration reproduces the pre-change selection exactly.
|
||||
##
|
||||
## Run with plain:
|
||||
## nim c -r common_libs/tests/test_rack_membership.nim
|
||||
|
||||
import std/[random, tables, os, strutils]
|
||||
import gun_harness/gun_interface
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else: echo "FAIL: ", name; inc failures
|
||||
|
||||
proc recordHit(fw: var FitnessWindow, hit: bool) =
|
||||
fw.hits[fw.head] = hit
|
||||
fw.head = (fw.head + 1) mod WindowSize
|
||||
inc fw.count
|
||||
|
||||
proc seedWindow(t: var VirtualTracker, targetId, gunId, binIdx, hits, misses: int) =
|
||||
if targetId notin t.fitness:
|
||||
t.fitness[targetId] = newSeq[GunFitness](t.numGuns)
|
||||
var fw = addr t.fitness[targetId][gunId].bins[binIdx]
|
||||
for _ in 0..<hits: recordHit(fw[], true)
|
||||
for _ in 0..<misses: recordHit(fw[], false)
|
||||
|
||||
# ── mode derivation (server truth) ────────────────────────────────────────────
|
||||
|
||||
proc testModeFromEnemyCount() =
|
||||
check "rackMode: 1 enemy alive -> 1v1",
|
||||
rackMode(1) == rm1v1
|
||||
check "rackMode: 2+ enemies alive -> melee",
|
||||
rackMode(2) == rmMelee and rackMode(5) == rmMelee
|
||||
check "rackMode: 0 enemies (should not occur) stays melee, matching the radar",
|
||||
rackMode(0) == rmMelee
|
||||
|
||||
# ── pure filter ────────────────────────────────────────────────────────────────
|
||||
|
||||
proc testAdmittedGunsFiltering() =
|
||||
# 0: both, 1: only1v1, 2: onlyMelee, 3: off
|
||||
let mem = [rmBoth, rmOnly1v1, rmOnlyMelee, rmOff]
|
||||
check "admittedGuns(1v1) keeps both + only1v1, drops onlyMelee/off",
|
||||
admittedGuns(4, rm1v1, mem) == @[0, 1]
|
||||
check "admittedGuns(melee) keeps both + onlyMelee, drops only1v1/off",
|
||||
admittedGuns(4, rmMelee, mem) == @[0, 2]
|
||||
|
||||
proc testEmptyFilterFallsBackToFullRack() =
|
||||
let allOff = [rmOff, rmOff, rmOff, rmOff]
|
||||
check "admittedGuns: all-off rack degrades to the FULL rack (1v1)",
|
||||
admittedGuns(4, rm1v1, allOff) == @[0, 1, 2, 3]
|
||||
check "admittedGuns: all-off rack degrades to the FULL rack (melee)",
|
||||
admittedGuns(4, rmMelee, allOff) == @[0, 1, 2, 3]
|
||||
# A rack where only melee guns exist still falls back to all guns in 1v1.
|
||||
let onlyMelee = [rmOnlyMelee, rmOnlyMelee]
|
||||
check "admittedGuns: a rack with NO gun for the mode degrades to the full rack",
|
||||
admittedGuns(2, rm1v1, onlyMelee) == @[0, 1]
|
||||
|
||||
proc testEmptyMembershipMeansNoFiltering() =
|
||||
check "admittedGuns: an empty membership table filters nothing",
|
||||
admittedGuns(3, rm1v1, []) == @[0, 1, 2]
|
||||
check "rackAdmitted: an empty membership table admits every id",
|
||||
rackAdmitted(0, rm1v1, []) and rackAdmitted(99, rmMelee, [])
|
||||
|
||||
proc testRackAdmitted() =
|
||||
let mem = [rmBoth, rmOnly1v1, rmOff]
|
||||
check "rackAdmitted: both is admitted in every mode",
|
||||
rackAdmitted(0, rm1v1, mem) and rackAdmitted(0, rmMelee, mem)
|
||||
check "rackAdmitted: only1v1 is admitted only in 1v1",
|
||||
rackAdmitted(1, rm1v1, mem) and not rackAdmitted(1, rmMelee, mem)
|
||||
check "rackAdmitted: off is never admitted",
|
||||
not rackAdmitted(2, rm1v1, mem) and not rackAdmitted(2, rmMelee, mem)
|
||||
check "rackAdmitted: negative id is never admitted",
|
||||
not rackAdmitted(-1, rm1v1, mem)
|
||||
|
||||
# ── selection-level filtering ─────────────────────────────────────────────────
|
||||
|
||||
proc testSelectionFollowsMode() =
|
||||
# gun0 50%, gun1 100% (1v1 only), gun2 100% (melee only).
|
||||
var t = initTracker(3)
|
||||
seedWindow(t, 7, 0, 0, 25, 25)
|
||||
seedWindow(t, 7, 1, 0, 50, 0)
|
||||
seedWindow(t, 7, 2, 0, 50, 0)
|
||||
let mem = @[rmBoth, rmOnly1v1, rmOnlyMelee]
|
||||
randomize(7)
|
||||
var one: array[3, int]
|
||||
for _ in 0..<100: inc one[t.bestGun(7, rackMode = rm1v1, membership = mem)]
|
||||
var melee: array[3, int]
|
||||
for _ in 0..<100: inc melee[t.bestGun(7, rackMode = rmMelee, membership = mem)]
|
||||
check "selection: 1v1 rack only ever returns admitted guns {0,1}",
|
||||
one[2] == 0 and one[0] + one[1] == 100
|
||||
check "selection: 1v1 rack picks the only1v1 gun when it dominates",
|
||||
one[1] == 100
|
||||
check "selection: melee rack only ever returns admitted guns {0,2}",
|
||||
melee[1] == 0 and melee[0] + melee[2] == 100
|
||||
check "selection: melee rack picks the onlyMelee gun when it dominates",
|
||||
melee[2] == 100
|
||||
|
||||
proc testSelectionEmptyFilterFallsBack() =
|
||||
var t = initTracker(3)
|
||||
seedWindow(t, 7, 0, 0, 10, 40) # 20%
|
||||
seedWindow(t, 7, 1, 0, 50, 0) # 100%
|
||||
seedWindow(t, 7, 2, 0, 10, 40) # 20%
|
||||
let allOff = @[rmOff, rmOff, rmOff]
|
||||
randomize(11)
|
||||
var seen: array[3, int]
|
||||
for _ in 0..<100: inc seen[t.bestGun(7, rackMode = rm1v1, membership = allOff)]
|
||||
check "selection: an all-off rack still selects (falls back to full rack)",
|
||||
seen[0] + seen[1] + seen[2] == 100
|
||||
check "selection: the fallback still picks the best gun (gun 1)",
|
||||
seen[1] == 100
|
||||
|
||||
# ── env parsing / defaults ────────────────────────────────────────────────────
|
||||
|
||||
proc clearRackEnv() =
|
||||
for name in RackGunNames:
|
||||
delEnv("TR_RACK_" & name)
|
||||
|
||||
proc testParseMembership() =
|
||||
check "parse: 'both' -> rmBoth", parseRackMembership("both") == rmBoth
|
||||
check "parse: '1v1' -> rmOnly1v1", parseRackMembership("1v1") == rmOnly1v1
|
||||
check "parse: 'melee' -> rmOnlyMelee", parseRackMembership("melee") == rmOnlyMelee
|
||||
check "parse: 'off' -> rmOff", parseRackMembership("off") == rmOff
|
||||
check "parse: case/whitespace-insensitive",
|
||||
parseRackMembership(" 1V1 ") == rmOnly1v1 and
|
||||
parseRackMembership("MeLeE") == rmOnlyMelee
|
||||
check "parse: empty -> shipped default rmBoth",
|
||||
parseRackMembership("") == rmBoth
|
||||
check "parse: unknown -> rmBoth (never fatal)",
|
||||
parseRackMembership("banana") == rmBoth
|
||||
|
||||
proc testDefaultsAllBoth() =
|
||||
clearRackEnv()
|
||||
var allBoth = true
|
||||
for m in DefaultRackMembership:
|
||||
if m != rmBoth: allBoth = false
|
||||
check "defaults: the shipped membership table is all-`both`", allBoth
|
||||
let loaded = loadRackMembership()
|
||||
check "defaults: with a clean environment loadRackMembership() == shipped table",
|
||||
loaded == DefaultRackMembership
|
||||
check "defaults: RackGunNames covers the shipped 14-gun rack",
|
||||
RackGunNames.len == 14 and DefaultRackMembership.len == 14
|
||||
|
||||
proc testEnvOverrides() =
|
||||
clearRackEnv()
|
||||
putEnv("TR_RACK_TSETLIN", "1v1")
|
||||
putEnv("TR_RACK_DISPLACE", "melee")
|
||||
putEnv("TR_RACK_HEADON", "off")
|
||||
let m = loadRackMembership()
|
||||
check "env: TR_RACK_TSETLIN=1v1 moves only Tsetlin (id 2)",
|
||||
m[2] == rmOnly1v1
|
||||
check "env: TR_RACK_DISPLACE=melee moves only Displace (id 9)",
|
||||
m[9] == rmOnlyMelee
|
||||
check "env: TR_RACK_HEADON=off removes only HeadOn (id 0)",
|
||||
m[0] == rmOff
|
||||
check "env: every untouched gun keeps the all-`both` default",
|
||||
m[1] == rmBoth and m[3] == rmBoth and m[13] == rmBoth
|
||||
putEnv("TR_RACK_KNN", "banana")
|
||||
check "env: unknown values are ignored, not fatal",
|
||||
loadRackMembership()[12] == rmBoth
|
||||
clearRackEnv()
|
||||
|
||||
# ── parity: default path reproduces pre-change selection ─────────────────────
|
||||
|
||||
proc testDefaultReproducesPreChange() =
|
||||
## The pre-change selector is `bestGun(t, id)` (empty membership). With the
|
||||
## all-`both` default table and EITHER mode, the selection sequence must be
|
||||
## identical, RNG draw for RNG draw.
|
||||
var t = initTracker(5)
|
||||
seedWindow(t, 7, 0, 0, 60, 40)
|
||||
seedWindow(t, 7, 1, 0, 40, 60)
|
||||
seedWindow(t, 7, 2, 0, 80, 20)
|
||||
seedWindow(t, 7, 3, 0, 55, 45)
|
||||
seedWindow(t, 7, 4, 0, 20, 80)
|
||||
let allBoth = @[rmBoth, rmBoth, rmBoth, rmBoth, rmBoth]
|
||||
randomize(1234)
|
||||
var pre: seq[int]
|
||||
for _ in 0..<300: pre.add t.bestGun(7)
|
||||
randomize(1234)
|
||||
var as1v1: seq[int]
|
||||
for _ in 0..<300: as1v1.add t.bestGun(7, rackMode = rm1v1, membership = allBoth)
|
||||
randomize(1234)
|
||||
var asMelee: seq[int]
|
||||
for _ in 0..<300: asMelee.add t.bestGun(7, rackMode = rmMelee, membership = allBoth)
|
||||
check "parity: all-`both` membership reproduces pre-change selection (1v1)",
|
||||
pre == as1v1
|
||||
check "parity: all-`both` membership reproduces pre-change selection (melee)",
|
||||
pre == asMelee
|
||||
|
||||
proc testDefaultReproducesPreChangeSelectGun() =
|
||||
## Same parity check through the live hysteresis path (`selectGun`).
|
||||
var t = initTracker(4)
|
||||
seedWindow(t, 7, 0, 0, 60, 40)
|
||||
seedWindow(t, 7, 1, 0, 70, 30)
|
||||
seedWindow(t, 7, 2, 0, 40, 60)
|
||||
seedWindow(t, 7, 3, 0, 50, 50)
|
||||
let allBoth = @[rmBoth, rmBoth, rmBoth, rmBoth]
|
||||
randomize(99)
|
||||
var a = initTracker(4)
|
||||
a = t
|
||||
var pre: seq[int]
|
||||
for tick in 0..<300: pre.add a.selectGun(7, tick = tick)
|
||||
randomize(99)
|
||||
var b = initTracker(4)
|
||||
b = t
|
||||
var post: seq[int]
|
||||
for tick in 0..<300:
|
||||
post.add b.selectGun(7, tick = tick, rackMode = rmMelee, membership = allBoth)
|
||||
check "parity: selectGun with all-`both` membership is byte-identical",
|
||||
pre == post
|
||||
|
||||
# ── driver ────────────────────────────────────────────────────────────────────
|
||||
|
||||
testModeFromEnemyCount()
|
||||
testAdmittedGunsFiltering()
|
||||
testEmptyFilterFallsBackToFullRack()
|
||||
testEmptyMembershipMeansNoFiltering()
|
||||
testRackAdmitted()
|
||||
testSelectionFollowsMode()
|
||||
testSelectionEmptyFilterFallsBack()
|
||||
testParseMembership()
|
||||
testDefaultsAllBoth()
|
||||
testEnvOverrides()
|
||||
testDefaultReproducesPreChange()
|
||||
testDefaultReproducesPreChangeSelectGun()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
quit(1)
|
||||
echo "\nAll rack-membership checks passed."
|
||||
Reference in New Issue
Block a user