diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 42da05b..a935a5b 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -27,6 +27,7 @@ import guns/knn_gun import guns/tm_selector import movements/phantom_meteor import movements/rammer +import movements/ram_decision import movements/the_floor_is_lava import movements/the_floor_is_lava_ring import movement_harness/virtual_bodies as mvb @@ -158,6 +159,10 @@ type ramStuckTicks: int ramDurationTicks: int ramCooldownTicks: int + ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage + ramDmgHead: int + ramDmgAccum: float ## damage accumulated this turn, flushed by the window + lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason) moveTracker: VirtualBodyTracker virtualHits: int virtualMiss: int @@ -502,6 +507,7 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) = bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y) method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) = + bot.ramDmgAccum += e.bullet.power # feeds the ram bullet-rain abort window bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY()) method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) = @@ -640,6 +646,10 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) = bot.ramStuckTicks = 0 bot.ramDurationTicks = 0 bot.ramCooldownTicks = 0 + for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0 + bot.ramDmgHead = 0 + bot.ramDmgAccum = 0.0 + bot.lastRamLogKey = "" bot.hasContact = false bot.tick = 0 bot.currentGun = -1 @@ -704,6 +714,32 @@ proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool = if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity return false +proc pushRamDamageWindow(bot: ModularBot) = + ## Advance the incoming-damage window once per turn. Damage accumulated by + ## `onHitByBullet` during the preceding event dispatch lands in the slot being + ## written. Called before the contact check so the window always advances. + bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum + bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow + bot.ramDmgAccum = 0.0 + +proc logRamChange(bot: ModularBot, on: bool, reason: RamReason, + dist: float, ws: WorldState, dmgRate: float, + offReason = "") = + ## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The + ## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram + ## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same + ## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset. + if not RamLog: return + let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason + if key == bot.lastRamLogKey: return + bot.lastRamLogKey = key + if on: + echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " & + fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " & + "band=[0,50]" + else: + echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn" + method run*(bot: ModularBot) = while isRunning(): inc bot.tick @@ -716,6 +752,10 @@ method run*(bot: ModularBot) = if bot.ramCooldownTicks > 0: dec bot.ramCooldownTicks + # Advance the incoming-damage window once per turn (hits accumulated by + # onHitByBullet during the preceding dispatch). + bot.pushRamDamageWindow() + # Safety: reset invalid target before any logic if bot.currentTargetId >= 0: if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or @@ -750,27 +790,42 @@ method run*(bot: ModularBot) = bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y) bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy) - # Ram decision — harness decides, not the movement module (uses fresh lastState) + # Ram decision — harness decides, not the movement module (uses fresh lastState). + # Triggers + abort live in the pure `ram_decision` module so they can be + # swept/unit-tested; the cooldown/duration/stuck machinery stays here. let ws = bot.lastState - let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY) - let ramFinisher = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy - let ramOpportunity = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 50.0 and ws.selfEnergy > ws.enemyEnergy + 30.0 - let ramDesperation = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0 - let shouldRam = bot.currentTargetId >= 0 and - (ramFinisher or ramOpportunity or ramDesperation) + let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY) + let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow) + # Change-of-plan signal: the selected gun's pooled virtual hit rate. Only + # consulted when the (default-off) plan trigger is enabled. + var gunHitRate = 0.0 + if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0: + gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true) + let ramReason = ramTrigger(RamInputs( + dist: ramDist, selfEnergy: ws.selfEnergy, + enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate)) + let triggerRam = bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and + ramReason != rrNone + # Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is + # unaffected, so a firefight before the approach never vetoes the start. + let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate) + let shouldRam = triggerRam and not abortRam if shouldRam and not bot.isRamming: bot.isRamming = true bot.ramStuckTicks = 0 bot.ramDurationTicks = 0 - if ramFinisher: - echo "[ram:enter] reason=finisher dist=", ramDist.int, " selfEnergy=", ws.selfEnergy.int, " enemyEnergy=", ws.enemyEnergy.int - elif ramOpportunity: - echo "[ram:enter] reason=opportunity dist=", ramDist.int, " energyAdv=", (ws.selfEnergy - ws.enemyEnergy).int - elif ramDesperation: - echo "[ram:enter] reason=desperation selfEnergy=", ws.selfEnergy.int + elif abortRam and bot.isRamming: + # Bullet rain: drop the ram, return to the normal range band, KEEP the + # target. The cooldown stops an immediate re-trigger while fire persists. + bot.isRamming = false + bot.ramStuckTicks = 0 + bot.ramDurationTicks = 0 + bot.ramCooldownTicks = 30 + bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain") elif not shouldRam and bot.isRamming: bot.isRamming = false + bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone") if bot.isRamming: inc bot.ramDurationTicks @@ -784,17 +839,24 @@ method run*(bot: ModularBot) = bot.ramStuckTicks = 0 bot.ramDurationTicks = 0 bot.ramCooldownTicks = 30 + bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "stuck") elif bot.ramDurationTicks > 60: bot.isRamming = false bot.currentTargetId = -1 bot.ramStuckTicks = 0 bot.ramDurationTicks = 0 bot.ramCooldownTicks = 30 + bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration") + + # TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits + # once per transition — including a reason change while already ramming. + if bot.isRamming: + bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate) # Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and - # ramming through the single ring engine (ram is just band [0, 50]); the ram - # DECISION above is unchanged. The default `tfil` keeps the exact old - # two-engine behaviour (tfil when holding, rammer when ramming). + # ramming through the single ring engine (ram is just band [0, 50]); + # `shouldRam` already accounts for the bullet-rain abort. The default `tfil` + # keeps the old two-engine behaviour (tfil when holding, rammer when ramming). var spd, tr: float if MovementName == "tfil_ring": bot.ringMover.band = diff --git a/common_libs/movements/ram_decision.nim b/common_libs/movements/ram_decision.nim new file mode 100644 index 0000000..140a5e3 --- /dev/null +++ b/common_libs/movements/ram_decision.nim @@ -0,0 +1,143 @@ +## Ram decision — the PURE trigger + abort logic for proactive ramming. +## +## ── Why this exists ───────────────────────────────────────────────────────── +## The old triggers lived inline in `ModularBot.run()` and were a chicken-and-egg +## loop: `ramOpportunity` demanded `dist < 50px`, but the mover had no reason to +## close, so the closest approach measured against DrussGT was 118.7px and the +## `<50px` trigger never fired. The decision must fire at a range the mover can +## actually close FROM, not only when the bot is already on top of the enemy. +## +## This module is pure (no battle, no Java, no bot API) so the trigger and abort +## can be unit-tested and swept. `ModularBot.run()` composes it with the +## cooldown/duration/stuck machinery, which stays in the bot. +## +## ── Honest framing (do not oversell) ──────────────────────────────────────── +## Ram damage is 0.6 per CONTACT EVENT, one-shot (positions rewind on contact), +## NOT 0.6/turn — small next to a p=3.0 bullet hit (16 dmg). The payoff is that +## at point-blank the hit probability approaches 1, so heavy bullets stop +## missing; ram damage also scores 2.0/pt (highest in the game) and a ram kill +## carries a 0.30 bonus vs 0.20 for a bullet kill. Ramming is therefore +## "force the fight to point-blank where our guns cannot miss", an OPPORTUNISTIC +## tactic, not a strategy (measured base rate: 2 collisions in the whole fixture +## corpus, 0 in 15 rounds vs DrussGT). +## +## ── Env knobs (read once at module init, like the gun rack) ───────────────── +## TR_RAM_OPP_DIST default 200.0 ramOpportunity max distance (was 50) +## TR_RAM_OPP_MARGIN default 15.0 ramOpportunity energy advantage (was 30) +## TR_RAM_ABORT_DMG default 0.5 abort an in-progress ram when the +## incoming damage rate exceeds this /turn +## TR_RAM_PLAN default off enable the change-of-plan trigger +## TR_RAM_PLAN_DIST default 250.0 change-of-plan max distance +## TR_RAM_PLAN_MARGIN default 20.0 change-of-plan energy advantage +## TR_RAM_PLAN_HITRATE default 0.05 selected gun's pooled virtual hit rate +## below which the gun duel counts as failing +## TR_RAM_LOG=1 emit one change-gated `[ram]` line + +import std/[os, strutils] + +proc getEnvFloat(name: string, default: float): float = + let s = getEnv(name, "") + if s.len == 0: return default + try: result = parseFloat(s.strip()) + except ValueError: result = default + +proc getEnvBool(name: string, default: bool): bool = + let s = getEnv(name, "").strip().toLowerAscii() + if s.len == 0: return default + s in ["1", "true", "yes", "on"] + +const + ## Finisher / desperation keep their original gates: the finisher is already + ## proactive, and the desperation case is a last-ditch, short-range play. + RamFinisherDist* = 300.0 + RamFinisherEnergy* = 20.0 + RamDesperationDist* = 150.0 + RamDesperationEnergy* = 5.0 + ## Number of turns the incoming-damage window averages over. 15 turns ≈ 0.75s + ## at 20 turns/s: short enough to react to a burst, long enough that a single + ## stray hit does not abort the approach. + RamDamageWindow* = 15 + ## Shipped defaults for the env-overridable knobs. + DefaultRamOppDist* = 200.0 + DefaultRamOppMargin* = 15.0 + DefaultRamAbortDmg* = 0.5 + DefaultRamPlanDist* = 250.0 + DefaultRamPlanMargin* = 20.0 + DefaultRamPlanHitRate* = 0.05 + +let RamOppDist* = getEnvFloat("TR_RAM_OPP_DIST", DefaultRamOppDist) +let RamOppMargin* = getEnvFloat("TR_RAM_OPP_MARGIN", DefaultRamOppMargin) +let RamAbortDmg* = getEnvFloat("TR_RAM_ABORT_DMG", DefaultRamAbortDmg) +let RamPlanEnabled* = getEnvBool("TR_RAM_PLAN", false) +let RamPlanDist* = getEnvFloat("TR_RAM_PLAN_DIST", DefaultRamPlanDist) +let RamPlanMargin* = getEnvFloat("TR_RAM_PLAN_MARGIN", DefaultRamPlanMargin) +let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate) +let RamLog* = existsEnv("TR_RAM_LOG") + +type + RamReason* = enum + rrNone ## no trigger fires + rrFinisher ## enemy < 20 energy, we are healthier, dist < 300 + rrOpportunity ## we clearly out-energise and are close enough to close + rrDesperation ## both nearly dead, short range + rrPlan ## change of plan: out-energise, close, gun duel failing + + RamInputs* = object + dist*: float + selfEnergy*: float + enemyEnergy*: float + ## The selected gun's pooled virtual hit rate (0..1). Only consulted by the + ## change-of-plan trigger; ignored when that trigger is disabled. + gunHitRate*: float + +proc ramTrigger*(inp: RamInputs, + oppDist = RamOppDist, + oppMargin = RamOppMargin, + planEnabled = RamPlanEnabled, + planDist = RamPlanDist, + planMargin = RamPlanMargin, + planHitRate = RamPlanHitRate): RamReason = + ## Pure trigger evaluation. Returns the FIRST matching reason in priority + ## order, or `rrNone`. Cooldown/duration/abort are deliberately NOT here — the + ## caller composes those, so this function has no state and is unit-testable. + ## + ## The `oppDist`/`oppMargin`/... parameters default to the env-derived values + ## so the live path uses the knobs, while a test can pass the OLD gates to + ## reproduce the pre-change behaviour. + if inp.enemyEnergy <= 0.0: return rrNone + if inp.dist < RamFinisherDist and inp.enemyEnergy < RamFinisherEnergy and + inp.selfEnergy > inp.enemyEnergy: + return rrFinisher + if inp.dist < oppDist and inp.selfEnergy > inp.enemyEnergy + oppMargin: + return rrOpportunity + if inp.selfEnergy < RamDesperationEnergy and inp.enemyEnergy < RamDesperationEnergy and + inp.dist < RamDesperationDist: + return rrDesperation + if planEnabled and inp.dist < planDist and + inp.selfEnergy > inp.enemyEnergy + planMargin and + inp.gunHitRate < planHitRate: + return rrPlan + rrNone + +proc reasonName*(r: RamReason): string = + case r + of rrNone: "none" + of rrFinisher: "finisher" + of rrOpportunity: "opportunity" + of rrDesperation: "desperation" + of rrPlan: "plan" + +proc damageRatePerTurn*(window: openArray[float]): float = + ## Mean incoming damage per turn over the window. Pure; an empty or all-zero + ## window returns 0.0 (never NaN). + if window.len == 0: return 0.0 + var total = 0.0 + for v in window: total += v + total / window.len.float + +proc shouldAbortRam*(ramming: bool, dmgRate: float, + abortDmg = RamAbortDmg): bool = + ## True when an ALREADY-IN-PROGRESS ram should be abandoned because we are + ## taking sustained fire on the way in. A ram that has not started is never + ## aborted (a firefight before the approach must not veto the start). + ramming and dmgRate > abortDmg diff --git a/common_libs/tests/test_ram_decision.nim b/common_libs/tests/test_ram_decision.nim new file mode 100644 index 0000000..fd6434f --- /dev/null +++ b/common_libs/tests/test_ram_decision.nim @@ -0,0 +1,144 @@ +## Pure unit guard for the ram decision (`movements/ram_decision.nim`). +## +## NO battle, NO Java, NO server. Run with: +## nim c -r common_libs/tests/test_ram_decision.nim +## +## These pin the two contracts the proactive-ram change depends on: +## 1. the opportunity trigger can fire at a distance the mover can actually +## close FROM (the old `dist < 50` gate could not — the closest measured +## approach vs DrussGT was 118.7px, so it never fired); +## 2. the bullet-rain abort only applies to an IN-PROGRESS ram and is driven by +## the windowed incoming-damage rate. + +import std/math +import movements/ram_decision + +var failures = 0 +proc check(name: string, ok: bool) = + if ok: echo "PASS: ", name + else: echo "FAIL: ", name; inc failures + +# ── 1. the shipped defaults are the RELAXED gates ──────────────────────────── + +proc testDefaults() = + check "default opportunity distance is relaxed to 200 (was 50)", + DefaultRamOppDist == 200.0 + check "default opportunity margin is relaxed to 15 (was 30)", + DefaultRamOppMargin == 15.0 + check "default bullet-rain abort threshold is 0.5/turn", + DefaultRamAbortDmg == 0.5 + check "change-of-plan hit-rate bar defaults to 0.05", + DefaultRamPlanHitRate == 0.05 + +# ── 2. opportunity fires at a closable distance (the user's scenario) ──────── + +proc testOpportunityAtClosableRange() = + # The exact shape the diagnosis used: dist 143, we are clearly healthier. + let inp = RamInputs(dist: 143.0, selfEnergy: 78.0, enemyEnergy: 41.0, gunHitRate: 0.0) + check "opportunity fires at dist 143 with a 37-energy lead", + ramTrigger(inp) == rrOpportunity + # Same state under the OLD gate must NOT fire: this is the chicken-and-egg. + check "old gate (dist<50, margin+30) does NOT fire at dist 143", + ramTrigger(inp, oppDist = 50.0, oppMargin = 30.0) == rrNone + # Just inside the new distance edge fires; just outside does not. + check "opportunity fires at 199px", ramTrigger(inp, oppDist = 200.0) == rrOpportunity + check "opportunity does NOT fire at 201px", + ramTrigger(RamInputs(dist: 201.0, selfEnergy: 78.0, enemyEnergy: 41.0)) == rrNone + +# ── 3. the energy margin is env-tunable and enforced ───────────────────────── + +proc testMargin() = + # 9-energy lead is below the default 15 margin, so no opportunity... + let thin = RamInputs(dist: 143.0, selfEnergy: 50.0, enemyEnergy: 41.0) + check "9-energy lead does NOT trigger at the default margin", + ramTrigger(thin) == rrNone + # ...but a looser margin (sweepable via TR_RAM_OPP_MARGIN) lets it fire. + check "9-energy lead fires with a 5-energy margin", + ramTrigger(thin, oppMargin = 5.0) == rrOpportunity + # A stricter margin vetoes a lead that the default accepts. + check "37-energy lead vetoed by a 40-energy margin", + ramTrigger(RamInputs(dist: 143.0, selfEnergy: 78.0, enemyEnergy: 41.0), + oppMargin = 40.0) == rrNone + +# ── 4. finisher + desperation keep working and take priority ───────────────── + +proc testFinisherAndDesperation() = + # Finisher: enemy nearly dead, we are healthier, proactive distance. + check "finisher fires at dist 250 against a 12-energy enemy", + ramTrigger(RamInputs(dist: 250.0, selfEnergy: 60.0, enemyEnergy: 12.0)) == rrFinisher + # Finisher outranks opportunity when both match. + check "finisher takes priority over opportunity", + ramTrigger(RamInputs(dist: 40.0, selfEnergy: 80.0, enemyEnergy: 12.0)) == rrFinisher + # Desperation: both nearly dead, short range. + check "desperation fires when both are nearly dead", + ramTrigger(RamInputs(dist: 100.0, selfEnergy: 3.0, enemyEnergy: 3.0)) == rrDesperation + # Dead enemy never triggers anything. + check "zero enemy energy never triggers", + ramTrigger(RamInputs(dist: 20.0, selfEnergy: 100.0, enemyEnergy: 0.0)) == rrNone + +# ── 5. change-of-plan trigger is opt-in and needs a failing gun ────────────── + +proc testPlanTrigger() = + # dist 220 is OUTSIDE the opportunity gate (200) but inside the plan gate + # (250), so only the change-of-plan trigger can match here. + let inp = RamInputs(dist: 220.0, selfEnergy: 80.0, enemyEnergy: 50.0, gunHitRate: 0.01) + # Disabled (the default): the plan trigger must NOT fire. + check "plan trigger does not fire while disabled", + ramTrigger(inp, planEnabled = false) == rrNone + # Enabled + poor gun hit rate: fires. + check "plan trigger fires when enabled and the gun rate is poor", + ramTrigger(inp, planEnabled = true) == rrPlan + # Enabled + good gun hit rate: does not fire. + check "plan trigger does not fire when the gun rate is healthy", + ramTrigger(RamInputs(dist: 220.0, selfEnergy: 80.0, enemyEnergy: 50.0, + gunHitRate: 0.30), + planEnabled = true) == rrNone + # Enabled but not out-energising enough: does not fire. + check "plan trigger does not fire without the energy lead", + ramTrigger(RamInputs(dist: 220.0, selfEnergy: 55.0, enemyEnergy: 50.0, + gunHitRate: 0.01), + planEnabled = true) == rrNone + +# ── 6. the incoming-damage window ──────────────────────────────────────────── + +proc testDamageRate() = + check "empty window -> 0.0/turn", damageRatePerTurn(newSeq[float]()) == 0.0 + check "all-zero window -> 0.0/turn", damageRatePerTurn([0.0, 0.0, 0.0]) == 0.0 + check "window mean is exact", + abs(damageRatePerTurn([0.0, 0.6, 0.0, 1.2, 0.0]) - 0.36) < 1e-12 + check "one hit in a 15-turn window reads low (0.04/turn)", + abs(damageRatePerTurn([0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - 0.04) < 1e-12 + +proc testAbort() = + check "no abort before the ram has started", + not shouldAbortRam(ramming = false, dmgRate = 5.0) + check "no abort at exactly the threshold", + not shouldAbortRam(ramming = true, dmgRate = 0.5) + check "abort above the threshold", + shouldAbortRam(ramming = true, dmgRate = 0.51) + check "threshold is overridable", + shouldAbortRam(ramming = true, dmgRate = 0.51, abortDmg = 0.6) == false and + shouldAbortRam(ramming = true, dmgRate = 0.61, abortDmg = 0.6) == true + +proc testReasonNames() = + check "reason names match the log vocabulary", + reasonName(rrNone) == "none" and reasonName(rrFinisher) == "finisher" and + reasonName(rrOpportunity) == "opportunity" and + reasonName(rrDesperation) == "desperation" and reasonName(rrPlan) == "plan" + +# ── driver ─────────────────────────────────────────────────────────────────── + +testDefaults() +testOpportunityAtClosableRange() +testMargin() +testFinisherAndDesperation() +testPlanTrigger() +testDamageRate() +testAbort() +testReasonNames() + +if failures > 0: + echo "\n", failures, " check(s) FAILED" + quit(1) +echo "\nAll ram-decision checks passed."