## 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. This module made the trigger fire at a range the ## mover can reach FROM, so it could be unit-tested and swept. ## ## ── The measured verdict: proactive ramming does not convert ──────────────── ## A log-replay diagnosis (49 rounds/arm, no new battles) showed the relaxed ## opportunity gate fires but NEVER reaches contact: ## opportunity -> contact = 0/6 (base), 0/40 (ring), 0/12 (ringhot), 0/1 (nopower) ## abort reasons: triggerGone 94/108 (87%), duration 14, stuck 0, bulletRain 0 ## opportunity episodes never got below ~80px (base closest median 126px) ## ticks during ram: <40:10, 40-80:16, 80-120:60, 120-200:165, >=200:85 ## Two smoking guns: a base episode ran the FULL 60-tick duration cap and closed ## only 198 -> 171px; a perfectly aligned, full-speed episode closed 195 -> 114px ## then PLATEAUED. The geometric reason: both bots have MAX_SPEED = 8, so a ## straight-line pursuit can NEVER catch an evading equal-speed opponent. Closing ## needs INTERCEPTION/cornering — a movement problem, not a trigger problem — and ## it is disproportionate to 0.6 damage per contact. ## ## Therefore the DEFAULT gate is FINISHER-ONLY (plus the rare desperation case). ## The finisher is the ONE proactive conversion in the corpus because a <20-energy ## DrussGT STOPS FLEEING (that episode closed at 6-8 px/tick and reached contact). ## See `docs/ramming_negative_result.md`. Do not re-attempt a proactive ## straight-line ram. ## ## ── 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. ## ## ── Env knobs (read once at module init, like the gun rack) ───────────────── ## TR_RAM_OPPORTUNITY default off re-enable the proactive opportunity gate ## TR_RAM_OPP_DIST default 200.0 opportunity max distance (only if enabled) ## TR_RAM_OPP_MARGIN default 15.0 opportunity energy advantage (only if enabled) ## TR_RAM_ABORT_DMG default 2.0 abort an in-progress ram when the ## incoming damage rate exceeds this /turn ## (real ENERGY units, see `bulletDamage`) ## 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 is the only converting path; 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. DefaultRamOppEnabled* = false DefaultRamOppDist* = 200.0 DefaultRamOppMargin* = 15.0 ## Real ENERGY per turn (the server's `calcBulletDamage`, not raw firepower). ## 2.0/turn ≈ 30 energy over the 15-turn window: high enough that ordinary ## exchange fire before the approach cannot veto a finisher on the tick after ## it starts, low enough that a genuine sustained barrage can abort it. DefaultRamAbortDmg* = 2.0 DefaultRamPlanDist* = 250.0 DefaultRamPlanMargin* = 20.0 DefaultRamPlanHitRate* = 0.05 let RamOppEnabled* = getEnvBool("TR_RAM_OPPORTUNITY", DefaultRamOppEnabled) 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 bulletDamage*(power: float): float = ## Real energy removed from us by a bullet of `power`, matching the server's ## `rules/math.kt calcBulletDamage`: `4 * firepower` for `firepower <= 1`, plus ## `2 * (firepower - 1)` above 1 (so p=3.0 -> 16, p=1.0 -> 4). The ram abort ## must compare against this, NOT against raw firepower. if power <= 0.0: return 0.0 var p = power if p < 0.1: p = 0.1 elif p > 3.0: p = 3.0 result = 4.0 * p if power > 1.0: result += 2.0 * (power - 1.0) proc ramTrigger*(inp: RamInputs, oppEnabled = RamOppEnabled, 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. ## ## `opportunity` is OFF by default (see the module header): it fired in the ## diagnosis but converted 0/59 times. The `oppEnabled`/`oppDist`/... parameters ## default to the env-derived values, so the live path uses the knobs while a ## test can reproduce the old proactive behaviour by passing `oppEnabled = true`. ## `desperation` and `finisher` are kept: they are rare, short-range, and the ## finisher is the only measured conversion. `plan` remains opt-in and off. if inp.enemyEnergy <= 0.0: return rrNone if inp.dist < RamFinisherDist and inp.enemyEnergy < RamFinisherEnergy and inp.selfEnergy > inp.enemyEnergy: return rrFinisher if oppEnabled and 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 energy per turn over the window (window slots hold the ## `bulletDamage` sum of the hits landed that turn). 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. `dmgRate` is real energy per turn ## (`bulletDamage` over `RamDamageWindow` turns); a ram that has not started is ## never aborted (a firefight before the approach must not veto the start). ramming and dmgRate > abortDmg