Ram: finisher-only by default, and the bullet-rain abort now measures real energy

Follows the diagnosis that proactive straight-line ramming CANNOT work: both bots
have MAX_SPEED=8, so a pursuit cannot catch an evading equal-speed opponent.
Measured over 49 rounds per arm, opportunity -> contact was **0/6** (base), 0/40
(ring), 0/12 (ringhot). The only proactive conversion in the whole corpus came
from a FINISHER, and only because a <20-energy DrussGT stops fleeing (that episode
closed at 6-8 px/tick). Opportunity episodes never got below ~80px; one ran the
full 60-tick duration cap and closed only 198->171px; a perfectly aligned
full-speed one closed 195->114px then plateaued.

CHANGES
- **Finisher-only default.** `finisher` (<20 energy, dist<300, we are healthier)
  and the rare `desperation` (both <5, dist<150) are kept; `opportunity` and the
  speculative `plan` are OFF. Both are env-reenableable with no rebuild:
  `TR_RAM_OPPORTUNITY=1` (tune via TR_RAM_OPP_DIST/MARGIN) and `TR_RAM_PLAN=1`.
  Justification: it removes 100+ non-converting episodes per fixture at zero
  measured loss (oldram vs base was p=0.69, damage 279 vs 284, survival 17/49 vs
  16/49) - and each of those episodes spent up to 60 ticks driving STRAIGHT at
  the enemy, abandoning the mover's dodging and disrupting aim.
- **`desperation` KEPT** deliberately: it is cheap and rare, fires only when both
  bots are nearly dead at short range (a coin-flip where 0.6 contact can decide
  it), and it is not the refuted straight-line pursuit.
- **THE BULLET-RAIN ABORT WAS DEAD CODE AND IS NOW FIXED.** `onHitByBullet`
  accumulated raw bullet FIREPOWER while `TR_RAM_ABORT_DMG = 0.5` was documented
  as a DAMAGE rate - so the bar was implicitly "sum of power > 7.5 over 15 turns"
  and the maximum rate ever observed was 0.27. It now accumulates REAL ENERGY via
  a `bulletDamage(power)` helper matching the server's `4p` / `6p-2` formula, and
  `TR_RAM_ABORT_DMG` defaults to **2.0 energy/turn** (~30 HP over 15 turns):
  "abort an in-progress ram if we take > 2.0 energy per turn". Same effective bar
  for normal firepower, and it can now actually fire - the live run reports
  `dmgRate=1.07/turn` where the old units said 0.27.
- **`ramStuckTicks` REMOVED.** It required `dist < 5px`; contact occurs at ~36px
  (two 18px radii) and position rewind prevents getting closer, so it could never
  increment. Only the 60-tick duration cap can now self-end a ram.

LIVENESS (measured, default config, vs a charging Java RamFire, 3 rounds):
  default              -> `[ram] ON reason=finisher` x3, `reason=opportunity` x0
  TR_RAM_OPPORTUNITY=1 -> `reason=opportunity` x4, `reason=finisher` x2
So the opportunity states DID occur and are suppressed by the new default - the
removal is real, not an arm that never fires. A line also read
`[ram] OFF reason=duration dmgRate=1.07/turn`, confirming the new energy units.

Adds docs/ramming_negative_result.md (70 lines) recording the question, the five
diagnostic answers, the geometric reason, the finisher exception, the two dead
code paths, and an explicit "do not re-attempt a proactive straight-line ram; if
point-blank forcing is ever wanted it is an INTERCEPTION/cornering movement
problem" note - the same pattern that stopped the corpse bug recurring.

Guards: test_ram_decision 40 (was 28), test_gun_harness 39, test_vbullet_metric 11,
test_power_selection 3, test_adaptive_radar 41, test_tfil_ring_weights 24,
test_power_policy 26, test_rack_membership 48, test_selector_tiebreak 19,
test_tm_pattern_registration 20, test_vbullet_admit_gate 12,
acceptance_offline_vs_online 12/12. ModularBot compiles.

Honest note: the abort-threshold fix is a real (tiny) behaviour change, NOT
measured-neutral - it only bites while a finisher ram is under sustained fire,
which is exactly the user's stated wish. The finisher-only removal itself is
measured-neutral per the given A/B.
This commit is contained in:
2026-09-22 08:14:03 +02:00
parent 99f9532f55
commit eb74f9b2e3
5 changed files with 217 additions and 85 deletions
+10 -19
View File
@@ -178,7 +178,6 @@ type
ringMover: TFILRingModule
rammer: RammerModule
isRamming: bool
ramStuckTicks: int
ramDurationTicks: int
ramCooldownTicks: int
ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage
@@ -560,7 +559,10 @@ 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
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
# 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real
# energy/turn rate (see movements/ram_decision.bulletDamage).
bot.ramDmgAccum += bulletDamage(e.bullet.power)
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
@@ -719,7 +721,6 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
# next round cannot inherit the previous round's overfit net.
bot.tmPattern.resetLearning()
bot.isRamming = false
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
bot.ramCooldownTicks = 0
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
@@ -875,7 +876,7 @@ method run*(bot: ModularBot) =
# 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.
# swept/unit-tested; the cooldown/duration machinery stays here.
let ws = bot.lastState
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow)
@@ -896,13 +897,11 @@ method run*(bot: ModularBot) =
if shouldRam and not bot.isRamming:
bot.isRamming = true
bot.ramStuckTicks = 0
bot.ramDurationTicks = 0
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")
@@ -910,23 +909,15 @@ method run*(bot: ModularBot) =
bot.isRamming = false
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone")
# Only the 60-tick duration cap can end a ram by itself. The old `stuck`
# counter required dist < 5px on consecutive ticks, but contact happens at
# ~36px (two 18px radii) and positions rewind, so it could never increment;
# it has been removed (see docs/ramming_negative_result.md).
if bot.isRamming:
inc bot.ramDurationTicks
if ramDist < 5.0:
inc bot.ramStuckTicks
else:
bot.ramStuckTicks = 0
if bot.ramStuckTicks > 10:
if 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, "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")
+62 -21
View File
@@ -4,28 +4,43 @@
## 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.
## `<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.
##
## 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.
## ── 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. 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).
## 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_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
## 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
@@ -48,7 +63,8 @@ proc getEnvBool(name: string, default: bool): bool =
const
## Finisher / desperation keep their original gates: the finisher is already
## proactive, and the desperation case is a last-ditch, short-range play.
## 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
@@ -58,13 +74,19 @@ const
## stray hit does not abort the approach.
RamDamageWindow* = 15
## Shipped defaults for the env-overridable knobs.
DefaultRamOppEnabled* = false
DefaultRamOppDist* = 200.0
DefaultRamOppMargin* = 15.0
DefaultRamAbortDmg* = 0.5
## 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)
@@ -90,7 +112,20 @@ type
## 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,
@@ -101,14 +136,18 @@ proc ramTrigger*(inp: RamInputs,
## 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.
## `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
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:
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:
@@ -128,7 +167,8 @@ proc reasonName*(r: RamReason): string =
of rrPlan: "plan"
proc damageRatePerTurn*(window: openArray[float]): float =
## Mean incoming damage per turn over the window. Pure; an empty or all-zero
## 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
@@ -138,6 +178,7 @@ proc damageRatePerTurn*(window: openArray[float]): 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).
## 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
+72 -45
View File
@@ -3,12 +3,13 @@
## NO battle, NO Java, NO server. Run with:
## nim c -r common_libs/tests/test_ram_decision.nim
##
## These pin the two contracts the proactive-ram change depends on:
## 1. the opportunity trigger can fire at a distance the mover can actually
## close FROM (the old `dist < 50` gate could not — the closest measured
## approach vs DrussGT was 118.7px, so it never fired);
## 2. the bullet-rain abort only applies to an IN-PROGRESS ram and is driven by
## the windowed incoming-damage rate.
## These pin the FINISHER-ONLY default and the two contracts it depends on:
## 1. the proactive `opportunity` gate is OFF by default (it fired but
## converted 0/59 times — see docs/ramming_negative_result.md), yet stays
## re-enableable via `oppEnabled`/`TR_RAM_OPPORTUNITY`;
## 2. the bullet-rain abort is driven by REAL ENERGY per turn (`bulletDamage`,
## the server's 4p/6p-2 formula), not raw firepower, and only applies to an
## IN-PROGRESS ram.
import std/math
import movements/ram_decision
@@ -18,58 +19,70 @@ proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
# ── 1. the shipped defaults are the RELAXED gates ────────────────────────────
# ── 1. the shipped default is FINISHER-ONLY ──────────────────────────────────
proc testDefaults() =
check "default opportunity distance is relaxed to 200 (was 50)",
check "opportunity is disabled by default", DefaultRamOppEnabled == false
check "the module's opportunity flag is off at init", not RamOppEnabled
check "the plan trigger is disabled by default", not RamPlanEnabled
check "opportunity distance default is 200 (used only when re-enabled)",
DefaultRamOppDist == 200.0
check "default opportunity margin is relaxed to 15 (was 30)",
check "opportunity margin default is 15 (used only when re-enabled)",
DefaultRamOppMargin == 15.0
check "default bullet-rain abort threshold is 0.5/turn",
DefaultRamAbortDmg == 0.5
check "bullet-rain abort threshold defaults to 2.0 energy/turn",
DefaultRamAbortDmg == 2.0
check "change-of-plan hit-rate bar defaults to 0.05",
DefaultRamPlanHitRate == 0.05
# ── 2. opportunity fires at a closable distance (the user's scenario) ────────
# ── 2. opportunity does NOT fire by default (the whole point) ────────────────
proc testOpportunityAtClosableRange() =
# The exact shape the diagnosis used: dist 143, we are clearly healthier.
proc testOpportunityOffByDefault() =
# The exact shape the old diagnosis used: dist 143, 37-energy lead.
let inp = RamInputs(dist: 143.0, selfEnergy: 78.0, enemyEnergy: 41.0, gunHitRate: 0.0)
check "opportunity fires at dist 143 with a 37-energy lead",
ramTrigger(inp) == rrOpportunity
# Same state under the OLD gate must NOT fire: this is the chicken-and-egg.
check "the 143px / +37-energy opportunity does NOT fire by default",
ramTrigger(inp) == rrNone
# ...but it IS re-enableable: the same state under the old proactive gate.
check "the same state fires when opportunity is re-enabled",
ramTrigger(inp, oppEnabled = true) == rrOpportunity
# The re-enabled gate still respects its distance edge.
check "re-enabled opportunity fires at 199px",
ramTrigger(RamInputs(dist: 199.0, selfEnergy: 78.0, enemyEnergy: 41.0),
oppEnabled = true) == rrOpportunity
check "re-enabled opportunity does NOT fire at 201px",
ramTrigger(RamInputs(dist: 201.0, selfEnergy: 78.0, enemyEnergy: 41.0),
oppEnabled = true) == rrNone
# The OLD (pre-change) 50px/+30 gates never fired at a closable range.
check "old gate (dist<50, margin+30) does NOT fire at dist 143",
ramTrigger(inp, oppDist = 50.0, oppMargin = 30.0) == rrNone
# Just inside the new distance edge fires; just outside does not.
check "opportunity fires at 199px", ramTrigger(inp, oppDist = 200.0) == rrOpportunity
check "opportunity does NOT fire at 201px",
ramTrigger(RamInputs(dist: 201.0, selfEnergy: 78.0, enemyEnergy: 41.0)) == rrNone
ramTrigger(inp, oppEnabled = true, oppDist = 50.0, oppMargin = 30.0) == rrNone
# ── 3. the energy margin is env-tunable and enforced ─────────────────────────
# ── 3. the energy margin is still enforced when re-enabled ───────────────────
proc testMargin() =
# 9-energy lead is below the default 15 margin, so no opportunity...
# 9-energy lead is below the default 15 margin, so no opportunity even enabled.
let thin = RamInputs(dist: 143.0, selfEnergy: 50.0, enemyEnergy: 41.0)
check "9-energy lead does NOT trigger at the default margin",
check "9-energy lead does NOT trigger at the default margin (disabled anyway)",
ramTrigger(thin) == rrNone
# ...but a looser margin (sweepable via TR_RAM_OPP_MARGIN) lets it fire.
check "9-energy lead fires with a 5-energy margin",
ramTrigger(thin, oppMargin = 5.0) == rrOpportunity
# A stricter margin vetoes a lead that the default accepts.
check "9-energy lead still does NOT trigger with opportunity enabled",
ramTrigger(thin, oppEnabled = true) == rrNone
# ...but a looser margin (TR_RAM_OPP_MARGIN) lets it fire once enabled.
check "9-energy lead fires with a 5-energy margin when enabled",
ramTrigger(thin, oppEnabled = true, oppMargin = 5.0) == rrOpportunity
# A stricter margin vetoes a lead the default accepts.
check "37-energy lead vetoed by a 40-energy margin",
ramTrigger(RamInputs(dist: 143.0, selfEnergy: 78.0, enemyEnergy: 41.0),
oppMargin = 40.0) == rrNone
oppEnabled = true, oppMargin = 40.0) == rrNone
# ── 4. finisher + desperation keep working and take priority ─────────────────
# ── 4. finisher + desperation keep working by default and take priority ──────
proc testFinisherAndDesperation() =
# Finisher: enemy nearly dead, we are healthier, proactive distance.
check "finisher fires at dist 250 against a 12-energy enemy",
ramTrigger(RamInputs(dist: 250.0, selfEnergy: 60.0, enemyEnergy: 12.0)) == rrFinisher
# Finisher outranks opportunity when both match.
check "finisher takes priority over opportunity",
ramTrigger(RamInputs(dist: 40.0, selfEnergy: 80.0, enemyEnergy: 12.0)) == rrFinisher
# Desperation: both nearly dead, short range.
check "finisher takes priority over (re-enabled) opportunity",
ramTrigger(RamInputs(dist: 40.0, selfEnergy: 80.0, enemyEnergy: 12.0),
oppEnabled = true) == rrFinisher
# Desperation: both nearly dead, short range — kept by default.
check "desperation fires when both are nearly dead",
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 3.0, enemyEnergy: 3.0)) == rrDesperation
# Dead enemy never triggers anything.
@@ -99,27 +112,40 @@ proc testPlanTrigger() =
gunHitRate: 0.01),
planEnabled = true) == rrNone
# ── 6. the incoming-damage window ────────────────────────────────────────────
# ── 6. bullet power -> real energy damage (the units fix) ────────────────────
proc testBulletDamage() =
# Matches the server: 4*firepower, plus 2*(firepower-1) above 1.
check "power 0.0 -> 0.0 energy", bulletDamage(0.0) == 0.0
check "power 0.5 -> 2.0 energy", bulletDamage(0.5) == 2.0
check "power 1.0 -> 4.0 energy", bulletDamage(1.0) == 4.0
check "power 2.0 -> 10.0 energy (8 + 2)", bulletDamage(2.0) == 10.0
check "power 3.0 -> 16.0 energy (12 + 4)", bulletDamage(3.0) == 16.0
check "power below the minimum clamps to 0.1 -> 0.4",
abs(bulletDamage(0.01) - 0.4) < 1e-9
check "negative power -> 0.0 energy", bulletDamage(-1.0) == 0.0
# ── 7. the incoming-damage window ────────────────────────────────────────────
proc testDamageRate() =
check "empty window -> 0.0/turn", damageRatePerTurn(newSeq[float]()) == 0.0
check "all-zero window -> 0.0/turn", damageRatePerTurn([0.0, 0.0, 0.0]) == 0.0
check "window mean is exact",
abs(damageRatePerTurn([0.0, 0.6, 0.0, 1.2, 0.0]) - 0.36) < 1e-12
check "one hit in a 15-turn window reads low (0.04/turn)",
abs(damageRatePerTurn([0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - 0.04) < 1e-12
abs(damageRatePerTurn([0.0, 4.0, 0.0, 8.0, 0.0]) - 2.4) < 1e-12
check "one p=1 hit in a 15-turn window reads low (0.27/turn)",
abs(damageRatePerTurn([4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) - 4.0 / 15.0) < 1e-12
proc testAbort() =
check "no abort before the ram has started",
not shouldAbortRam(ramming = false, dmgRate = 5.0)
not shouldAbortRam(ramming = false, dmgRate = 50.0)
check "no abort at exactly the threshold",
not shouldAbortRam(ramming = true, dmgRate = 0.5)
not shouldAbortRam(ramming = true, dmgRate = 2.0)
check "abort above the threshold",
shouldAbortRam(ramming = true, dmgRate = 0.51)
shouldAbortRam(ramming = true, dmgRate = 2.01)
check "threshold is overridable",
shouldAbortRam(ramming = true, dmgRate = 0.51, abortDmg = 0.6) == false and
shouldAbortRam(ramming = true, dmgRate = 0.61, abortDmg = 0.6) == true
shouldAbortRam(ramming = true, dmgRate = 2.01, abortDmg = 3.0) == false and
shouldAbortRam(ramming = true, dmgRate = 3.01, abortDmg = 3.0) == true
proc testReasonNames() =
check "reason names match the log vocabulary",
@@ -130,10 +156,11 @@ proc testReasonNames() =
# ── driver ───────────────────────────────────────────────────────────────────
testDefaults()
testOpportunityAtClosableRange()
testOpportunityOffByDefault()
testMargin()
testFinisherAndDesperation()
testPlanTrigger()
testBulletDamage()
testDamageRate()
testAbort()
testReasonNames()
+3
View File
@@ -25,6 +25,9 @@ p=0.69, damage 279 vs 284, survival 17/49 vs 16/49 (p=1.0), ram contacts 1 vs 2.
provably LIVE (6 `opportunity` ON events vs **0** under the old 50px/+30 gates, and `base` reached
<40px on 10 ticks vs 0) — but it converts to essentially no extra collisions and no measurable
outcome change. Safe to keep; it is not earning its keep, and reverting it is equally defensible.
**Follow-up:** a log-replay diagnosis later showed `opportunity -> contact` was **0/59** and the
gate never got below ~80px, so the default is now **finisher-only** and the proactive trigger is
opt-in (`TR_RAM_OPPORTUNITY=1`). See `docs/ramming_negative_result.md`.
**3. Range-weighted ring mover — HURTS the objective. Do NOT ship.** See below; this is the
important one.
+70
View File
@@ -0,0 +1,70 @@
# Proactive ramming: a negative result (do not re-attempt straight-line pursuit)
**Question.** The bot declined visible ram opportunities ("it could jump over the
enemy and shred it"). The proactive `opportunity` gate was relaxed to fire from a
closable range (`dist < 200`, energy lead `> 15`, was `dist < 50`). Did it convert?
**No** — a log-replay diagnosis (no new battles), 49 rounds/arm: it fires, never
reaches contact.
## The five measurements
| # | Diagnostic | Result |
|---|---|---|
| 1 | **opportunity -> contact** | **0/6** (base), **0/40** (ring), **0/12** (ringhot), **0/1** (nopower) |
| 2 | **abort reasons** | `triggerGone` **94/108 (87%)**, `duration` 14, `stuck` **0**, `bulletRain` **0** |
| 3 | **closest approach in an opportunity episode** | never below ~80 px; base closest **median 126 px** |
| 4 | **ticks spent during ram** | `<40: 10, 40-80: 16, 80-120: 60, 120-200: 165, >=200: 85` |
| 5 | **A/B, `oldram` vs `base`** | p=**0.69**, damage **279 vs 284**, survival **17/49 vs 16/49** (p=1.0) |
The only proactive conversion in the corpus came from a **finisher** (enemy at
16 -> 1 energy), not an opportunity. Two smoking guns: a base episode ran the FULL
60-tick `duration` cap and closed only **198 -> 171 px**; a perfectly aligned
(`delta ~= 0 deg`), full-speed-8 episode closed **195 -> 114 px** then PLATEAUED.
## Why it cannot work (the geometric reason)
Both bots have `MAX_SPEED = 8`. A straight-line pursuit can **never** catch an
evading equal-speed opponent: every turn spent turning is a turn the evader pulls
away, and any juke re-opens the gap. Closing requires **interception** (cutting
off the escape path) or **cornering**, not chasing. That is a *movement* problem,
and it is disproportionate to **0.6 damage per contact** (one-shot; NOT 0.6/turn).
Corroboration: cornering was independently refuted (wall-adjacent enemies are
*less* predictable; closest approach to DrussGT in 15 rounds was 118.7 px). Both
routes to point-blank have now failed for the same underlying reason.
## The one exception: the finisher
The finisher (`enemy < 20 energy`, `dist < 300`, we are healthier) DOES convert,
because a **<20-energy DrussGT stops fleeing**: that episode closed at 6-8 px/tick
and reached contact. It is kept and is now the **default** gate. The rare
`desperation` case (both < 5 energy, `dist < 150`) is kept as a cheap last-ditch
play. `opportunity` and `plan` are OFF by default.
**Re-enable for re-testing:** `TR_RAM_OPPORTUNITY=1` (with `TR_RAM_OPP_DIST`,
`TR_RAM_OPP_MARGIN`) restores the proactive gate; `TR_RAM_PLAN=1` restores the
change-of-plan trigger. Verified live: the same ModularBot-vs-RamFire battle logs
**0 opportunity ON** by default and **4 opportunity ON** with `TR_RAM_OPPORTUNITY=1`.
## The two dead code paths, dealt with
1. **`bulletRain` abort — units fixed.** `onHitByBullet` accumulated bullet
**firepower**, while `TR_RAM_ABORT_DMG = 0.5`/turn implied a *damage* rate. The
server's damage is `calcBulletDamage(p) = 4p` (plus `2*(p-1)` above 1), so the
abort was ~4x too insensitive and never fired (max observed `dmgRate` 0.27 in
all 108 OFF lines). It now accumulates **real energy** via `bulletDamage()` and
the threshold is `TR_RAM_ABORT_DMG = 2.0` **energy/turn** over 15 turns (~30 HP)
— the same effective bar for normal firepower, now in correct units. The live
run reports `dmgRate=1.07/turn` (was 0.27) and does not abort the finisher.
2. **`ramStuckTicks` — removed.** It required `dist < 5 px` on consecutive ticks,
but two 18 px radii make contact occur at ~36 px and collision resolution
rewinds positions — so the closest approach is ~36 px and the counter could
never increment (diagnostic: `stuck 0`). Removed rather than documented as
protection it cannot provide; the 60-tick `duration` cap is the only self-abort.
## Do not re-attempt a proactive straight-line ram
Do **not** re-relax the opportunity gate, add "get closer" logic to satisfy it, or
add a new chase trigger. Any future attempt at forcing point-blank must be an
**interception/cornering** movement problem (predict the escape path and occupy
it), and must clear the same `opportunity -> contact` bar above. Proactive ramming
is worth at most 0.6 damage per contact and measured **zero** outcome change.