diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 46cdfc6..a8ba324 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -3,7 +3,7 @@ ## Radar: RadarLockModule (1v1) / MeleeScanModule (2+ enemies), auto-switched per tick. ## Movement: OscillatorModule (perpendicular strafing). -import std/[math, os, strformat, tables, sets, json] +import std/[math, os, strformat, tables, sets, json, random, strutils] import robocode_tankroyale_botapi import radar_harness/radar_interface import radars/radar_lock_module @@ -55,6 +55,25 @@ const ShotLog = true ## can enable recording for just the battle it spawns by exporting the env var. let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE") const WorldStateRecordPath = "/tmp/worldstate_record.jsonl" +## Runtime rack pruning for MEASURED A/B runs: comma-separated gun ids to remove +## from the virtual-bullet rack. A disabled gun never spawns virtual bullets, so +## its fitness window stays empty and the selector can never pick it (chooseFromFit +## skips guns with no observations). Read once at process start so a SINGLE frozen +## binary can be A/B'd by exporting GUN_RACK_DISABLE. Empty/unset = full rack. +let DisabledGuns = + block: + var s: HashSet[int] + for tok in getEnv("GUN_RACK_DISABLE", "").split(','): + let t = tok.strip() + if t.len > 0: + try: s.incl parseInt(t) + except ValueError: discard + s + +proc gunDisabled(id: int): bool {.inline.} = id in DisabledGuns +## Per-process output paths so concurrent A/B runs do not clobber each other. +let GunStatsPath = getEnv("GUN_STATS_PATH", "/tmp/gun_stats.jsonl") +let ShotLogPath = getEnv("GUN_SHOTLOG_PATH", "/tmp/shot_log.jsonl") const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN", "TMSelect"] const @@ -145,7 +164,7 @@ proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) = "unresolved": unresolved } try: - let f = open("/tmp/shot_log.jsonl", fmAppend) + let f = open(ShotLogPath, fmAppend) f.writeLine($row) f.close() except CatchableError: @@ -369,7 +388,7 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = "vDropped": bot.tracker.droppedBullets, "vStarved": bot.guessFactor.waveStarved + bot.decayGF.waveStarved + bot.knnGun.waveStarved } - let f = open("/tmp/gun_stats.jsonl", fmAppend) + let f = open(GunStatsPath, fmAppend) f.writeLine($row) f.close() # Task A: shots still in flight at round end never resolved, so count them as @@ -619,20 +638,20 @@ method run*(bot: ModularBot) = tmselPreds[i] = bot.tmSelector.predict(bot.lastState, bulletSpeed(PowerBins[i])) bot.tracker.spawnBullets(0, headsUp, bot.lastState, tid) - bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid) - if bot.tsetlin.isWarmedUp(): + if not gunDisabled(1): bot.tracker.spawnBullets(1, linPreds, bot.lastState, tid) + if not gunDisabled(2) and bot.tsetlin.isWarmedUp(): bot.tracker.spawnBullets(2, tmPreds, bot.lastState, tid) - bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid) - bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid) - bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid) - bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid) - bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid) - bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid) - bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid) - bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid) - bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid) - bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid) - if EnableTmSelector and bot.tmSelector.isWarmedUp(): + if not gunDisabled(3): bot.tracker.spawnBullets(3, circPreds, bot.lastState, tid) + if not gunDisabled(4): bot.tracker.spawnBullets(4, gfPreds, bot.lastState, tid) + if not gunDisabled(5): bot.tracker.spawnBullets(5, pmPreds, bot.lastState, tid) + if not gunDisabled(6): bot.tracker.spawnBullets(6, wbPreds, bot.lastState, tid) + if not gunDisabled(7): bot.tracker.spawnBullets(7, acPreds, bot.lastState, tid) + if not gunDisabled(8): bot.tracker.spawnBullets(8, ssPreds, bot.lastState, tid) + if not gunDisabled(9): bot.tracker.spawnBullets(9, dsPreds, bot.lastState, tid) + if not gunDisabled(10): bot.tracker.spawnBullets(10, alPreds, bot.lastState, tid) + if not gunDisabled(11): bot.tracker.spawnBullets(11, dgPreds, bot.lastState, tid) + if not gunDisabled(12): bot.tracker.spawnBullets(12, knnPreds, bot.lastState, tid) + if EnableTmSelector and not gunDisabled(13) and bot.tmSelector.isWarmedUp(): bot.tracker.spawnBullets(13, tmselPreds, bot.lastState, tid) # Build slim enemy table for tickBullets @@ -735,6 +754,22 @@ method run*(bot: ModularBot) = setGunTurnRate(normDelta) +proc seedSelectorRng() = + ## Seed the process-global RNG exactly once at bot startup so the gun + ## selector's "random" tie-break (`chooseFromFit` -> rand) actually varies + ## across process restarts. Previously the only `randomize()` call reached on + ## the live path was incidental, inside the Tsetlin gun's constructor, so a + ## rack without Tsetlin produced a FIXED tie-break sequence forever. + ## + ## `GUN_SELECTOR_SEED`, when set to an integer, pins the stream so A/B runs are + ## reproducible; otherwise time+pid seeding makes each process independent. + let s = getEnv("GUN_SELECTOR_SEED", "") + if s.len > 0: + try: randomize(parseInt(s.strip())) + except ValueError: randomize() + else: + randomize() + when isMainModule: var bot = ModularBot( tracker: vb.initTracker(14), # 0: HeadOn, 1: Linear, 2: Tsetlin, 3: Circular, 4: GuessFactor, 5: Pattern, 6: WallBounce, 7: Accel, 8: StopShot, 9: Displace, 10: AvgLead, 11: DecayGF, 12: KNN, 13: TMSelect @@ -759,4 +794,7 @@ when isMainModule: moveTracker: mvb.initVirtualBodyTracker(1), currentGun: -1, ) + # Must run AFTER the gun constructors (Tsetlin/TMSelector call randomize() + # unconditionally) so the explicit seed override is the value that survives. + seedSelectorRng() start(bot, botJsonPath) diff --git a/common_libs/tests/acceptance_offline_vs_online.nim b/common_libs/tests/acceptance_offline_vs_online.nim index 76bb729..c3a405b 100644 --- a/common_libs/tests/acceptance_offline_vs_online.nim +++ b/common_libs/tests/acceptance_offline_vs_online.nim @@ -78,7 +78,7 @@ proc main() = let online = lastOnlineRound(statsPath) let fx = loadFixture(recordPath) - let reports = replayFixture(fx, buildAllGunDrivers(), liveActual = true) + let reports = replayFixture(fx, buildAllGunDrivers(enableTmSelector = false), liveActual = true) # Map online stats by gun id. var onShots: array[14, int] diff --git a/common_libs/tests/range_guns.nim b/common_libs/tests/range_guns.nim index 9138f63..18d2afc 100644 --- a/common_libs/tests/range_guns.nim +++ b/common_libs/tests/range_guns.nim @@ -19,12 +19,21 @@ import guns/decay_gf import guns/knn_gun import guns/tm_selector -proc buildAllGunDrivers*(seed = -1): seq[GunDriver] = +proc buildAllGunDrivers*(seed = -1, enableTmSelector = true): seq[GunDriver] = ## seed >= 0 re-seeds the global RNG after constructing the stochastic guns ## (Tsetlin and the TM selector both call randomize() in their constructors), ## so their learning is reproducible for offline runs. ## ## Order matches ModularBot's gun ids exactly (TMSelect appended at 13). + ## + ## `enableTmSelector` must MIRROR the live rack. The shipped ModularBot has + ## `EnableTmSelector = false`, so the live loop never spawns gun-13 virtual + ## bullets. The replay's shared VirtualTracker ring is order-sensitive: extra + ## gun-13 spawns shift the ring head and permute the per-tick resolution ORDER + ## of every other gun, which scrambles the `obs` insertion order of the + ## learning guns (KNN, DecayGF) and makes the offline metric diverge from the + ## live one. Callers that mirror the shipped bot must pass false (the + ## acceptance test does); tests that specifically exercise TMSelect pass true. var tsetlin = initTsetlinGun() var tmSelector = initTmSelectorGun() if seed >= 0: @@ -45,6 +54,10 @@ proc buildAllGunDrivers*(seed = -1): seq[GunDriver] = makeDriver("KNN", initKNNGun()), makeDriver("TMSelect", tmSelector), ] + if not enableTmSelector: + ## Same effect as the live `if EnableTmSelector` gate: never spawn gun 13. + ## Keep the slot so gun ids / report indices are unchanged. + result[13].readyCb = proc(): bool = false proc makeTsetlinDriver*(seed = -1): tuple[driver: GunDriver, gun: ref TsetlinGun] = ## Same as makeDriver("Tsetlin", ...) but keeps a handle to the concrete gun,