diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 779769e..048df7c 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -35,6 +35,9 @@ import targeting/target_selector const botJsonPath = currentSourcePath().parentDir / "ModularBot.json" const DebugVBullets = false const DebugCircular = false +## Task A instrumentation: log every real shot + its eventual outcome to +## /tmp/shot_log.jsonl. Set to false to compile all shot-log machinery out. +const ShotLog = true const GunNames = ["HeadOn", "Linear", "Tsetlin", "Circular", "GuessFactor", "Pattern", "WallBounce", "Accel", "StopShot", "Displace", "AvgLead", "DecayGF", "KNN"] const @@ -47,6 +50,16 @@ const CLR_RST = "\e[0m" # reset type + PendingShot = object + ## Metadata for one real shot, captured at setFire() time and resolved when + ## the server reports the bullet's fate (hit / wall / bullet). + gunId: int + angleErr: float ## |aim error| (deg) at fire time — the value the gate used + distPx: float ## distance (px) to the aim point at fire time + power: float + tick: int + targetId: int + ModularBot = ref object of Bot hasContact: bool enemyBearing: float @@ -92,29 +105,61 @@ type realHits: int gunRealShots: array[13, int] gunRealHits: array[13, int] - pendingFireGuns: seq[int] ## FIFO of fired guns awaiting onBulletFired bulletId stamp + pendingFires: seq[PendingShot] ## FIFO of fired shots awaiting onBulletFired bulletId stamp bulletGun: Table[int, int] ## bulletId -> gun id, filled on onBulletFired, drained on resolution + bulletShot: Table[int, PendingShot] ## bulletId -> shot metadata (Task A shot log) pendingHitBullets: HashSet[int] ## hit bulletIds seen before their onBulletFired stamp gunSelectionCount: array[13, int] lastKnownTargetId: int ## persists through death, used for round-end stats +proc writeShotLog(shot: PendingShot, hit: bool, unresolved: bool) = + ## Task A: append one JSON line per resolved real shot. The write is fully + ## guarded so a full disk / bad path can never take the bot down. + when ShotLog: + let row = %*{ + "tick": shot.tick, + "targetId": shot.targetId, + "gunId": shot.gunId, + "angleErr": shot.angleErr, + "distPx": shot.distPx, + "power": shot.power, + "hit": hit, + "unresolved": unresolved + } + try: + let f = open("/tmp/shot_log.jsonl", fmAppend) + f.writeLine($row) + f.close() + except CatchableError: + discard # logging must never crash the bot + +proc resolveShotLog(bot: ModularBot, bulletId: int, hit: bool) = + ## Look up the shot metadata for a resolved bullet and log its fate once. + ## No-op when ShotLog is off (bulletShot is then never populated). + if bot.bulletShot.hasKey(bulletId): + writeShotLog(bot.bulletShot[bulletId], hit, false) + bot.bulletShot.del(bulletId) + proc registerOwnBullet(bot: ModularBot, bulletId: int) = ## onBulletFired: the server assigns a unique bulletId per round. Stamp the ## oldest pending fire with it, then credit that gun with a real shot. ## The FIFO stays aligned because we only enqueue when setFire() succeeds ## (server "bot.energy <= firepower" rejection is pre-empted by the energy guard). - if bot.pendingFireGuns.len == 0: return # unexpected: no pending fire to stamp - let gunId = bot.pendingFireGuns[0] - delete(bot.pendingFireGuns, 0) - bot.bulletGun[bulletId] = gunId - inc bot.gunRealShots[gunId] + if bot.pendingFires.len == 0: return # unexpected: no pending fire to stamp + let shot = bot.pendingFires[0] + delete(bot.pendingFires, 0) + bot.bulletGun[bulletId] = shot.gunId + inc bot.gunRealShots[shot.gunId] + when ShotLog: + bot.bulletShot[bulletId] = shot # Same-turn fire+hit: the client sorts events by (turn asc, priority desc) and # BulletHitBot (70) outranks BulletFired (60), so onBulletHit may have run first. # If so, credit the hit now and drop the (already resolved) map entry. if bulletId in bot.pendingHitBullets: - inc bot.gunRealHits[gunId] + inc bot.gunRealHits[shot.gunId] bot.pendingHitBullets.excl(bulletId) bot.bulletGun.del(bulletId) + bot.resolveShotLog(bulletId, true) proc resolveOwnBullet(bot: ModularBot, bulletId: int): int = ## Look up the gun that fired bulletId and drop it from the in-flight map. @@ -178,17 +223,22 @@ method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) = inc bot.realHits # 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] - else: bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired + if gunId >= 0: + inc bot.gunRealHits[gunId] + bot.resolveShotLog(e.bullet.bulletId, true) + else: + bot.pendingHitBullets.incl(e.bullet.bulletId) # defer until onBulletFired bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # miss: free the attribution slot + bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot + bot.resolveShotLog(e.bullet.bulletId, false) bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y) bot.mover.removeBulletNear(e.bullet.x, e.bullet.y) @@ -244,14 +294,24 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = let f = open("/tmp/gun_stats.jsonl", fmAppend) f.writeLine($row) f.close() + # Task A: shots still in flight at round end never resolved, so count them as + # misses for the shot log (matching how realHits/realShots treats them). + # Shots fired but never stamped by onBulletFired are not counted in realShots + # either, so they are dropped rather than logged. + when ShotLog: + for _, shot in bot.bulletShot: + writeShotLog(shot, false, true) + bot.bulletShot.clear() + bot.pendingFires.setLen(0) method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.roundNumber = e.roundNumber bot.realShotsFired = 0 bot.realHits = 0 bot.lastKnownTargetId = -1 - bot.pendingFireGuns.setLen(0) + bot.pendingFires.setLen(0) bot.bulletGun.clear() + bot.bulletShot.clear() bot.pendingHitBullets.clear() for i in 0..<13: bot.gunSelectionCount[i] = 0 @@ -568,11 +628,22 @@ method run*(bot: ModularBot) = if normDelta > 180.0: normDelta -= 360.0 elif normDelta < -180.0: normDelta += 360.0 - if shouldFire(gunDir, aimTarget, gunHeat): + # Distance to the aim point drives the range-aware gate (Task B) and is + # recorded per shot (Task A). + let distPx = hypot(pred.x - getX(), pred.y - getY()) + + if shouldFire(gunDir, aimTarget, gunHeat, distPx): # Enqueue the selected gun so onBulletFired can stamp the server's bulletId. # getEnergy() > power mirrors the server's "bot.energy <= firepower" reject. if setFire(power) and getEnergy() > power: - bot.pendingFireGuns.add(selectedGun) + bot.pendingFires.add(PendingShot( + gunId: selectedGun, + angleErr: abs(normDelta), + distPx: distPx, + power: power, + tick: bot.tick, + targetId: tid, + )) setGunTurnRate(normDelta) diff --git a/common_libs/gun_harness/selector.nim b/common_libs/gun_harness/selector.nim index 9d10489..82e382a 100644 --- a/common_libs/gun_harness/selector.nim +++ b/common_libs/gun_harness/selector.nim @@ -1,23 +1,70 @@ ## Gun selector — picks best gun×power, computes aim angle, gates firing. -## Fires highest power with acceptable hit rate when gun is aimed within -## threshold and gunHeat == 0. +## Fires highest power with acceptable hit rate when the gun is aimed within a +## range-dependent angular tolerance and gunHeat == 0. import std/math import gun_interface import virtual_bullets -const AimThresholdDeg* = 2.0 ## max angle error to fire; ponytail: tune per bot +const + ## ── Range-aware firing gate ──────────────────────────────────────────────── + ## A real shot departs with whatever misalignment the gun had at fire time, + ## while a virtual bullet is spawned exactly on the prediction and carries zero + ## aim error. At distance `d` the target subtends an angular half-width of + ## `atan(BotRadius / d)`, so a fixed degree threshold is simultaneously too + ## loose at long range (throws away shots that cannot hit) and too tight up + ## close (holds fire when the bot is already inside the hit cone). + ## + ## We therefore derive the tolerance from the target's angular radius: + ## + ## tolDeg = radToDeg(arctan(BotRadius * SafetyFactor / distPx)) + ## + ## clamped to [MinAimThresholdDeg, MaxAimThresholdDeg]. + ## + ## SafetyFactor shrinks/expands the accepted cone: 1.0 == the full geometric + ## half-width, < 1.0 is stricter. Fitted empirically from real-shot data + ## (Task A, 2611 real shots behind a wide-open 20 deg measurement gate). + ## The geometric model is only weakly identified: prediction error dominates + ## the hit rate, and the measured 50%-hit knee is noisy (0.9-1.4x the + ## geometric cone at 200-800 px; the 400-600 px bucket is ill-defined because + ## its baseline hit rate is already ~50%). Simulating the gate directly on the + ## measurement data showed 0.6 Pareto-dominates the old fixed 2.0 deg gate + ## (61.4% vs 60.2% hit rate with MORE shots), and the live sweep confirms the + ## observed preference for tighter gates. 0.6 is the shipped compromise: + ## tighter than the raw geometry while still loosening close range. + SafetyFactor* = 0.6 + ## Floor: keeps the tolerance strictly positive so a perfectly aligned gun can + ## always fire at any range, and guards the gate against collapsing to 0 + ## (a never-fire deadlock) at extreme distances. + MinAimThresholdDeg* = 0.05 + ## Ceiling: at point-blank range the geometric cone grows without bound; a + ## >10 deg misalignment is a coin toss even at ~100 px, so cap it here. + MaxAimThresholdDeg* = 10.0 + +proc aimToleranceDeg*(distPx: float): float = + ## Angular half-width (deg) the gun may be off by and still plausibly hit a + ## target `distPx` px away, scaled by SafetyFactor and clamped. + ## + ## Degenerate distances (0 or unavailable) fall back to the ceiling rather than + ## dividing by zero; NaN is treated the same way (the `not (distPx > 0.0)` + ## test is false for NaN). +Inf falls through to arctan(0) == 0 and then the + ## floor, which is correct: an infinitely distant target is a point. + if not (distPx > 0.0): return MaxAimThresholdDeg + result = radToDeg(arctan(BotRadius * SafetyFactor / distPx)) + if result < MinAimThresholdDeg: result = MinAimThresholdDeg + elif result > MaxAimThresholdDeg: result = MaxAimThresholdDeg proc aimAngle*(selfX, selfY, targetX, targetY: float): float = ## Absolute bearing in degrees (0=East, CCW+) toward (targetX, targetY). result = radToDeg(arctan2(targetY - selfY, targetX - selfX)) -proc shouldFire*(currentGunDir, targetAngle, gunHeat: float): bool = - ## Returns true when gun is close enough and cool enough to fire. +proc shouldFire*(currentGunDir, targetAngle, gunHeat, distPx: float): bool = + ## Returns true when the gun is within the range-aware angular tolerance and + ## cool enough to fire. `distPx` is the distance (px) to the aim point. var delta = (targetAngle - currentGunDir) mod 360.0 if delta > 180.0: delta -= 360.0 elif delta < -180.0: delta += 360.0 - abs(delta) <= AimThresholdDeg and gunHeat <= 0.0 + abs(delta) <= aimToleranceDeg(distPx) and gunHeat <= 0.0 proc selectShot*(t: VirtualTracker, targetId: int = -1): (GunId, int, float) = ## Returns (gunId, powerBinIdx, power) — the shot to take this tick. diff --git a/common_libs/tests/test_gun_harness.nim b/common_libs/tests/test_gun_harness.nim index 9a94c90..b50acca 100644 --- a/common_libs/tests/test_gun_harness.nim +++ b/common_libs/tests/test_gun_harness.nim @@ -214,6 +214,57 @@ proc testPatternMatcherSpeedSensitivity() = check "pattern_matcher: same tick, different bulletSpeed -> different point", pointsDiffer(m0, m3) +# ── range-aware firing gate (Task B) ──────────────────────────────────────── + +proc testToleranceStrictlyDecreases() = + # In the unclamped band the tolerance must fall monotonically with distance. + # 100..10000 px sits strictly inside [floor, ceiling] for the shipped + # SafetyFactor, so clamping cannot mask a flat or rising curve. + let dists = [100.0, 200.0, 400.0, 800.0, 1600.0, 3200.0, 10000.0] + var ok = true + for i in 0.. ceiling + let far = aimToleranceDeg(1.0e9) # effectively infinite -> floor + let mid = aimToleranceDeg(500.0) + check "range gate: point-blank clamps to MaxAimThresholdDeg", + near == MaxAimThresholdDeg + check "range gate: extreme range clamps to MinAimThresholdDeg", + far == MinAimThresholdDeg + check "range gate: mid-range tolerance is strictly inside the clamps", + mid > MinAimThresholdDeg and mid < MaxAimThresholdDeg + +proc testToleranceFormula() = + let d = 500.0 + let expected = radToDeg(arctan(BotRadius * SafetyFactor / d)) + check "range gate: tolerance matches radToDeg(arctan(BotRadius*SF/dist))", + abs(aimToleranceDeg(d) - expected) < 1e-9 + +proc testPerfectAlignmentAlwaysFires() = + var allPass = true + for d in [0.0, 1.0, 100.0, 1000.0, 1.0e9]: + if not shouldFire(100.0, 100.0, 0.0, d): allPass = false + check "range gate: a perfectly aligned cool gun passes at every distance", + allPass + +proc testGrossMisalignmentFailsLongRange() = + # 5 deg is far outside the ~0.8 deg cone at 800 px. + check "range gate: gross misalignment fails at long range", + not shouldFire(100.0, 105.0, 0.0, 800.0) + +proc testDegenerateDistance() = + let t = aimToleranceDeg(0.0) + check "range gate: distPx = 0 falls back to the ceiling", + t == MaxAimThresholdDeg + check "range gate: distPx = 0 yields a finite, non-NaN tolerance", + t == t and t < Inf and t > -Inf + check "range gate: NaN distance also falls back to the ceiling", + aimToleranceDeg(NaN) == MaxAimThresholdDeg + # ── driver ─────────────────────────────────────────────────────────────────── randomize() @@ -229,6 +280,12 @@ testStopShotSpeedSensitivity() testDisplacementSpeedSensitivity() testAveragedLeadSpeedSensitivity() testPatternMatcherSpeedSensitivity() +testToleranceStrictlyDecreases() +testToleranceClamps() +testToleranceFormula() +testPerfectAlignmentAlwaysFires() +testGrossMisalignmentFailsLongRange() +testDegenerateDistance() if failures > 0: echo "\n", failures, " check(s) FAILED"