diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index ec09410..1142afa 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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") diff --git a/common_libs/movements/ram_decision.nim b/common_libs/movements/ram_decision.nim index 140a5e3..4b6a003 100644 --- a/common_libs/movements/ram_decision.nim +++ b/common_libs/movements/ram_decision.nim @@ -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 diff --git a/common_libs/tests/test_ram_decision.nim b/common_libs/tests/test_ram_decision.nim index fd6434f..8d3f82a 100644 --- a/common_libs/tests/test_ram_decision.nim +++ b/common_libs/tests/test_ram_decision.nim @@ -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() diff --git a/docs/feature_ab_results.md b/docs/feature_ab_results.md index c90ed78..877714c 100644 --- a/docs/feature_ab_results.md +++ b/docs/feature_ab_results.md @@ -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. diff --git a/docs/ramming_negative_result.md b/docs/ramming_negative_result.md new file mode 100644 index 0000000..cf96e0b --- /dev/null +++ b/docs/ramming_negative_result.md @@ -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.