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:
2026-09-22 00:24:45 +02:00
parent c9825dfb0b
commit 994f88d7a7
3 changed files with 365 additions and 16 deletions
+78 -16
View File
@@ -27,6 +27,7 @@ import guns/knn_gun
import guns/tm_selector
import movements/phantom_meteor
import movements/rammer
import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movement_harness/virtual_bodies as mvb
@@ -158,6 +159,10 @@ type
ramStuckTicks: int
ramDurationTicks: int
ramCooldownTicks: int
ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage
ramDmgHead: int
ramDmgAccum: float ## damage accumulated this turn, flushed by the window
lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason)
moveTracker: VirtualBodyTracker
virtualHits: int
virtualMiss: int
@@ -502,6 +507,7 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
bot.ramDmgAccum += e.bullet.power # feeds the ram bullet-rain abort window
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
@@ -640,6 +646,10 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 0
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
bot.ramDmgHead = 0
bot.ramDmgAccum = 0.0
bot.lastRamLogKey = ""
bot.hasContact = false
bot.tick = 0
bot.currentGun = -1
@@ -704,6 +714,32 @@ proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity
return false
proc pushRamDamageWindow(bot: ModularBot) =
## Advance the incoming-damage window once per turn. Damage accumulated by
## `onHitByBullet` during the preceding event dispatch lands in the slot being
## written. Called before the contact check so the window always advances.
bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum
bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow
bot.ramDmgAccum = 0.0
proc logRamChange(bot: ModularBot, on: bool, reason: RamReason,
dist: float, ws: WorldState, dmgRate: float,
offReason = "") =
## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The
## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram
## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same
## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset.
if not RamLog: return
let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason
if key == bot.lastRamLogKey: return
bot.lastRamLogKey = key
if on:
echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " &
fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " &
"band=[0,50]"
else:
echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn"
method run*(bot: ModularBot) =
while isRunning():
inc bot.tick
@@ -716,6 +752,10 @@ method run*(bot: ModularBot) =
if bot.ramCooldownTicks > 0:
dec bot.ramCooldownTicks
# Advance the incoming-damage window once per turn (hits accumulated by
# onHitByBullet during the preceding dispatch).
bot.pushRamDamageWindow()
# Safety: reset invalid target before any logic
if bot.currentTargetId >= 0:
if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or
@@ -750,27 +790,42 @@ method run*(bot: ModularBot) =
bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y)
bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy)
# Ram decision — harness decides, not the movement module (uses fresh lastState)
# Ram decision — harness decides, not the movement module (uses fresh lastState).
# Triggers + abort live in the pure `ram_decision` module so they can be
# swept/unit-tested; the cooldown/duration/stuck machinery stays here.
let ws = bot.lastState
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
let ramFinisher = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy
let ramOpportunity = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 50.0 and ws.selfEnergy > ws.enemyEnergy + 30.0
let ramDesperation = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0
let shouldRam = bot.currentTargetId >= 0 and
(ramFinisher or ramOpportunity or ramDesperation)
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow)
# Change-of-plan signal: the selected gun's pooled virtual hit rate. Only
# consulted when the (default-off) plan trigger is enabled.
var gunHitRate = 0.0
if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0:
gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true)
let ramReason = ramTrigger(RamInputs(
dist: ramDist, selfEnergy: ws.selfEnergy,
enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate))
let triggerRam = bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and
ramReason != rrNone
# Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is
# unaffected, so a firefight before the approach never vetoes the start.
let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate)
let shouldRam = triggerRam and not abortRam
if shouldRam and not bot.isRamming:
bot.isRamming = true
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
if ramFinisher:
echo "[ram:enter] reason=finisher dist=", ramDist.int, " selfEnergy=", ws.selfEnergy.int, " enemyEnergy=", ws.enemyEnergy.int
elif ramOpportunity:
echo "[ram:enter] reason=opportunity dist=", ramDist.int, " energyAdv=", (ws.selfEnergy - ws.enemyEnergy).int
elif ramDesperation:
echo "[ram:enter] reason=desperation selfEnergy=", ws.selfEnergy.int
elif abortRam and bot.isRamming:
# Bullet rain: drop the ram, return to the normal range band, KEEP the
# target. The cooldown stops an immediate re-trigger while fire persists.
bot.isRamming = false
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain")
elif not shouldRam and bot.isRamming:
bot.isRamming = false
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone")
if bot.isRamming:
inc bot.ramDurationTicks
@@ -784,17 +839,24 @@ method run*(bot: ModularBot) =
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "stuck")
elif bot.ramDurationTicks > 60:
bot.isRamming = false
bot.currentTargetId = -1
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 30
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration")
# TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits
# once per transition — including a reason change while already ramming.
if bot.isRamming:
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
# ramming through the single ring engine (ram is just band [0, 50]); the ram
# DECISION above is unchanged. The default `tfil` keeps the exact old
# two-engine behaviour (tfil when holding, rammer when ramming).
# ramming through the single ring engine (ram is just band [0, 50]);
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
# keeps the old two-engine behaviour (tfil when holding, rammer when ramming).
var spd, tr: float
if MovementName == "tfil_ring":
bot.ringMover.band =