## 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."