## 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 ## TR_RAM_FLOOR_ENERGY default 0.0 FIRING FLOOR (j160). At or below this ## self energy we stop firing to keep a ## ram reserve. 0 = off = today's behaviour. ## TR_RAM_ENEMY_ENERGY default 0.0 ENEMY-EXHAUSTION trigger (j160). The ## last-scanned enemy energy <= this -> ## ram mode. 0 = off. ## ## ── j160: the energy-reserve + exhaustion policy ─────────────────────────── ## Energy NEVER regenerates and has no cap; the only gain in the whole game is ## `+3 * power` per bullet hit LANDED (server `rules.kt`). So not firing denies ## the enemy its only refill AND keeps our ram reserve intact — the two halves ## of the policy are the same bet. ## ## Floor sizing: one likely return hit (`bulletDamage(1.0)` = 4.0) plus two ## 0.1-power shots (0.1 each) is 4.2. The knob DEFAULT stays 0.0 so the default ## path is byte-identical; the operator sets 5-ish. ## ## The exhaustion trigger is the FINISHER with the energy tolerance promoted to ## an operator knob. It deliberately KEEPS the finisher's own ## `selfEnergy > enemyEnergy` surplus guard: `RAM_DAMAGE 0.6` is applied to BOTH ## bots on every contact tick, so a head-on contact is a symmetric bleed decided ## by who walks in with the surplus. 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") ## j160. 0.0 = off on BOTH knobs, which is the shipped behaviour. let RamFloorEnergy* = getEnvFloat("TR_RAM_FLOOR_ENERGY", 0.0) let RamEnemyEnergy* = getEnvFloat("TR_RAM_ENEMY_ENERGY", 0.0) type RamReason* = enum rrNone ## no trigger fires rrExhausted ## j160: last-scanned enemy energy <= TR_RAM_ENEMY_ENERGY ## and we hold the surplus (it is out of ammo, we are not) 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, enemyEnergyTol = RamEnemyEnergy): 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 # j160 exhaustion trigger. Checked FIRST so the operator-set tolerance wins # the label when it is set; it is the finisher's own shape (same surplus and # distance guards) with the 20.0 energy tolerance promoted to a knob. With # `enemyEnergyTol = 0.0` (the default) this arm can never fire. if enemyEnergyTol > 0.0 and inp.enemyEnergy <= enemyEnergyTol and inp.dist < RamFinisherDist and inp.selfEnergy > inp.enemyEnergy: return rrExhausted 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 fireFloorBlocks*(floor, selfEnergy: float, ramming = false): bool = ## j160 FIRING FLOOR. True when the reserve is thin enough that we must not ## commit a NEW shot. `floor = 0.0` (the default) disables the floor entirely ## and returns false for every input, so the default path is unchanged. ## ## `ramming` WINS over the floor: once ram mode is engaged the duel is over, ## so the reserve is being spent on the contact, not held for it. This is the ## same exemption `ramming` already gets in `applyPowerPolicy`. ## ## The floor blocks only NEW shots. A bullet already in the air (gun heat > 0) ## is untouched — `shouldFire` already gates on `gunHeat <= 0.0`, so there is ## no committed shot for the floor to suppress or cancel. not ramming and floor > 0.0 and selfEnergy <= floor proc reasonName*(r: RamReason): string = case r of rrNone: "none" of rrExhausted: "exhausted" 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