Files
SirStone eb74f9b2e3 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.
2026-09-22 08:14:03 +02:00

172 lines
8.7 KiB
Nim

## Pure unit guard for the ram decision (`movements/ram_decision.nim`).
##
## NO battle, NO Java, NO server. Run with:
## nim c -r common_libs/tests/test_ram_decision.nim
##
## 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
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
# ── 1. the shipped default is FINISHER-ONLY ──────────────────────────────────
proc testDefaults() =
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 "opportunity margin default is 15 (used only when re-enabled)",
DefaultRamOppMargin == 15.0
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 does NOT fire by default (the whole point) ────────────────
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 "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, oppEnabled = true, oppDist = 50.0, oppMargin = 30.0) == rrNone
# ── 3. the energy margin is still enforced when re-enabled ───────────────────
proc testMargin() =
# 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 (disabled anyway)",
ramTrigger(thin) == rrNone
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),
oppEnabled = true, oppMargin = 40.0) == rrNone
# ── 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 (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.
check "zero enemy energy never triggers",
ramTrigger(RamInputs(dist: 20.0, selfEnergy: 100.0, enemyEnergy: 0.0)) == rrNone
# ── 5. change-of-plan trigger is opt-in and needs a failing gun ──────────────
proc testPlanTrigger() =
# dist 220 is OUTSIDE the opportunity gate (200) but inside the plan gate
# (250), so only the change-of-plan trigger can match here.
let inp = RamInputs(dist: 220.0, selfEnergy: 80.0, enemyEnergy: 50.0, gunHitRate: 0.01)
# Disabled (the default): the plan trigger must NOT fire.
check "plan trigger does not fire while disabled",
ramTrigger(inp, planEnabled = false) == rrNone
# Enabled + poor gun hit rate: fires.
check "plan trigger fires when enabled and the gun rate is poor",
ramTrigger(inp, planEnabled = true) == rrPlan
# Enabled + good gun hit rate: does not fire.
check "plan trigger does not fire when the gun rate is healthy",
ramTrigger(RamInputs(dist: 220.0, selfEnergy: 80.0, enemyEnergy: 50.0,
gunHitRate: 0.30),
planEnabled = true) == rrNone
# Enabled but not out-energising enough: does not fire.
check "plan trigger does not fire without the energy lead",
ramTrigger(RamInputs(dist: 220.0, selfEnergy: 55.0, enemyEnergy: 50.0,
gunHitRate: 0.01),
planEnabled = true) == rrNone
# ── 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, 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 = 50.0)
check "no abort at exactly the threshold",
not shouldAbortRam(ramming = true, dmgRate = 2.0)
check "abort above the threshold",
shouldAbortRam(ramming = true, dmgRate = 2.01)
check "threshold is overridable",
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",
reasonName(rrNone) == "none" and reasonName(rrFinisher) == "finisher" and
reasonName(rrOpportunity) == "opportunity" and
reasonName(rrDesperation) == "desperation" and reasonName(rrPlan) == "plan"
# ── driver ───────────────────────────────────────────────────────────────────
testDefaults()
testOpportunityOffByDefault()
testMargin()
testFinisherAndDesperation()
testPlanTrigger()
testBulletDamage()
testDamageRate()
testAbort()
testReasonNames()
if failures > 0:
echo "\n", failures, " check(s) FAILED"
quit(1)
echo "\nAll ram-decision checks passed."