From a73de13458c8c4af5b534fb5242ab52e7af33099 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Tue, 22 Sep 2026 00:45:10 +0200 Subject: [PATCH] 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_` 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= 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. --- ModularBot_garage/src/ModularBot.nim | 69 +++++- common_libs/gun_harness/selector.nim | 104 ++++++++- common_libs/gun_harness/virtual_bullets.nim | 120 ++++++++-- common_libs/tests/test_rack_membership.nim | 238 ++++++++++++++++++++ 4 files changed, 506 insertions(+), 25 deletions(-) create mode 100644 common_libs/tests/test_rack_membership.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index a935a5b..d5353c9 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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) diff --git a/common_libs/gun_harness/selector.nim b/common_libs/gun_harness/selector.nim index f5946d8..27b463b 100644 --- a/common_libs/gun_harness/selector.nim +++ b/common_libs/gun_harness/selector.nim @@ -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_` 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 & "='" & value & + "'; falling back to 'both' (valid: both|1v1|melee|off)") + rmBoth + +proc loadRackMembership*(): array[len(RackGunNames), RackMembership] = + ## Default table plus every `TR_RACK_` override. A proc (not inlined into + ## the `let`) so the unit test can exercise env parsing in-process. + result = DefaultRackMembership + for i in 0.. 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.. 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.. 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) diff --git a/common_libs/gun_harness/virtual_bullets.nim b/common_libs/gun_harness/virtual_bullets.nim index 9c6b73f..2e8ced9 100644 --- a/common_libs/gun_harness/virtual_bullets.nim +++ b/common_libs/gun_harness/virtual_bullets.nim @@ -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..= 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 diff --git a/common_libs/tests/test_rack_membership.nim b/common_libs/tests/test_rack_membership.nim new file mode 100644 index 0000000..033f70e --- /dev/null +++ b/common_libs/tests/test_rack_membership.nim @@ -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_` 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.. 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."