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