## 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