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