ramming: make the decision PROACTIVE, with a bullet-rain abort
The user watched 1v1 and melee runs and saw ram opportunities arise that the bot
declined: "there were moments where the bot could jump over the enemy and shred
it but shot it down instead."
DIAGNOSIS - a chicken-and-egg loop. `ramOpportunity` required dist < 50px, but
an offline measurement over 15 rounds vs DrussGT found the closest approach was
118.7px and the <50px trigger had NEVER fired: the mover has no reason to close,
so the trigger waited for a proximity nothing created. The MECHANISM to close
already existed (the ring mover expresses a ram as band=(0,50)); what was
missing was a decision that fires at a range the bot can actually close from.
Changes:
- opportunity gate relaxed: dist 50 -> TR_RAM_OPP_DIST (200), energy margin
+30 -> TR_RAM_OPP_MARGIN (15). Both env-tunable, no rebuild needed.
- New pure module `common_libs/movements/ram_decision.nim` holding the trigger
and abort logic (no battle/API deps), so it is unit-testable.
- BULLET-RAIN ABORT (the user asked for this earlier): `onHitByBullet` now
accumulates `e.bullet.power` into a 15-turn ring; damageRatePerTurn = sum/15;
an in-progress ram aborts when rate > TR_RAM_ABORT_DMG (0.5/turn). On abort:
isRamming=false, cooldown 30, TARGET KEPT, and the mover returns to the normal
range band. It never stops the bot.
- Opt-in, DEFAULT-OFF `plan` trigger for "change of plan when the gun duel is
failing" (dist<250, margin+20, selected gun's pooled virtual rate < 0.05).
Left off because a cold gun reads 0.0 and would qualify - speculative.
- `TR_RAM_LOG=1` change-gated line: `[ram] ON reason=opportunity dist=143
selfE=78 enemyE=41 cap=3.0 band=[0,50]` / `[ram] OFF reason=bulletRain`.
- Existing cooldown/duration/stuck machinery untouched (stuck>10 or duration>60
-> abort + cooldown 30). A refactor bug that briefly DROPPED the
`ramCooldownTicks == 0` gate was caught and fixed.
Trigger set (first match wins): finisher (dist<300, enemy<20, we are healthier);
opportunity (dist<200, we lead by 15+); desperation (both <5, dist<150); plan
(off). All require enemy>0, a valid target, and no cooldown.
Proof the intent now fires at a closable distance (28/28 unit checks):
PASS: opportunity fires at dist 143 with a 37-energy lead <- the exact case
PASS: old gate (dist<50, margin+30) does NOT fire at 143 <- the old bug
PASS: fires at 199px / does NOT fire at 201px
+ margin, finisher priority, desperation, plan on/off, window mean, abort
threshold checks.
HONEST FRAMING: ram damage is 0.6 per CONTACT EVENT, one-shot (collision
resolution rewinds positions so contacts do not stream) - small next to a p=3.0
bullet hit (16). The payoff is that point-blank forces hit probability toward 1,
so heavy bullets stop missing and E[dE]=p(3P-1) turns positive above P=1/3; ram
damage also scores 2.0/point (highest in the game) and a ram kill carries a 0.30
bonus vs 0.20. So this is "force the fight to point-blank", not "the ram shreds
them". Base rate is rare (2 collisions in the whole fixture corpus).
Guards: test_ram_decision 28 (new), test_power_policy 26, test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24. Compiles (release).
UNVERIFIED: the live effect. No A/B has run, and whether the mover actually
reaches contact is unproven.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user