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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user