23bce2dad5
TR_RAM_FLOOR_ENERGY (0.0 = off): at/below this self energy we start no NEW shot, holding back the reserve for a final ram exchange. Justified by the only energy gain in the game being +3*power per bullet hit LANDED, so not firing denies the enemy its only refill. Blocks only NEW shots (gunHeat already gates committed ones) and is bypassed while ramming. TR_RAM_ENEMY_ENERGY (0.0 = off): last-scanned enemy energy <= this -> ram mode. Enemy energy IS observable (ScannedBotEvent.energy, schemas.nim:306), 1-8 ticks stale. This is the shipped finisher with its energy tolerance promoted to a knob, keeping the self>enemy surplus guard because RAM_DAMAGE 0.6 applies to BOTH bots on every contact tick. Open-loop measurement (measure_ramfloor_energy, 8149 recordings / 29871 rounds / 33.8M ticks): 'both low' is COMMON (10.4% of ticks below 20, 15.1% below 25) but neither side goes low first (enemy 52.7% / us 47.3%), and the owner's literal trigger - enemy so low it cannot fire (energy <= 1.95) - is only 2.5% of ticks, 1.1% while we are healthy. Guards 136 -> 147 in test_tfil_commit_env.nim, all green. A/B PRE-REGISTERED in docs/ram_floor_exhaustion_ab.md and NOT RUN.
235 lines
12 KiB
Nim
235 lines
12 KiB
Nim
## 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
|