ramming: make the decision PROACTIVE, with a bullet-rain abort
The user watched 1v1 and melee runs and saw ram opportunities arise that the bot
declined: "there were moments where the bot could jump over the enemy and shred
it but shot it down instead."
DIAGNOSIS - a chicken-and-egg loop. `ramOpportunity` required dist < 50px, but
an offline measurement over 15 rounds vs DrussGT found the closest approach was
118.7px and the <50px trigger had NEVER fired: the mover has no reason to close,
so the trigger waited for a proximity nothing created. The MECHANISM to close
already existed (the ring mover expresses a ram as band=(0,50)); what was
missing was a decision that fires at a range the bot can actually close from.
Changes:
- opportunity gate relaxed: dist 50 -> TR_RAM_OPP_DIST (200), energy margin
+30 -> TR_RAM_OPP_MARGIN (15). Both env-tunable, no rebuild needed.
- New pure module `common_libs/movements/ram_decision.nim` holding the trigger
and abort logic (no battle/API deps), so it is unit-testable.
- BULLET-RAIN ABORT (the user asked for this earlier): `onHitByBullet` now
accumulates `e.bullet.power` into a 15-turn ring; damageRatePerTurn = sum/15;
an in-progress ram aborts when rate > TR_RAM_ABORT_DMG (0.5/turn). On abort:
isRamming=false, cooldown 30, TARGET KEPT, and the mover returns to the normal
range band. It never stops the bot.
- Opt-in, DEFAULT-OFF `plan` trigger for "change of plan when the gun duel is
failing" (dist<250, margin+20, selected gun's pooled virtual rate < 0.05).
Left off because a cold gun reads 0.0 and would qualify - speculative.
- `TR_RAM_LOG=1` change-gated line: `[ram] ON reason=opportunity dist=143
selfE=78 enemyE=41 cap=3.0 band=[0,50]` / `[ram] OFF reason=bulletRain`.
- Existing cooldown/duration/stuck machinery untouched (stuck>10 or duration>60
-> abort + cooldown 30). A refactor bug that briefly DROPPED the
`ramCooldownTicks == 0` gate was caught and fixed.
Trigger set (first match wins): finisher (dist<300, enemy<20, we are healthier);
opportunity (dist<200, we lead by 15+); desperation (both <5, dist<150); plan
(off). All require enemy>0, a valid target, and no cooldown.
Proof the intent now fires at a closable distance (28/28 unit checks):
PASS: opportunity fires at dist 143 with a 37-energy lead <- the exact case
PASS: old gate (dist<50, margin+30) does NOT fire at 143 <- the old bug
PASS: fires at 199px / does NOT fire at 201px
+ margin, finisher priority, desperation, plan on/off, window mean, abort
threshold checks.
HONEST FRAMING: ram damage is 0.6 per CONTACT EVENT, one-shot (collision
resolution rewinds positions so contacts do not stream) - small next to a p=3.0
bullet hit (16). The payoff is that point-blank forces hit probability toward 1,
so heavy bullets stop missing and E[dE]=p(3P-1) turns positive above P=1/3; ram
damage also scores 2.0/point (highest in the game) and a ram kill carries a 0.30
bonus vs 0.20. So this is "force the fight to point-blank", not "the ram shreds
them". Base rate is rare (2 collisions in the whole fixture corpus).
Guards: test_ram_decision 28 (new), test_power_policy 26, test_gun_harness 39,
test_vbullet_metric 11, test_power_selection 3, test_adaptive_radar 41,
test_tfil_ring_weights 24. Compiles (release).
UNVERIFIED: the live effect. No A/B has run, and whether the mover actually
reaches contact is unproven.
This commit is contained in:
@@ -27,6 +27,7 @@ import guns/knn_gun
|
|||||||
import guns/tm_selector
|
import guns/tm_selector
|
||||||
import movements/phantom_meteor
|
import movements/phantom_meteor
|
||||||
import movements/rammer
|
import movements/rammer
|
||||||
|
import movements/ram_decision
|
||||||
import movements/the_floor_is_lava
|
import movements/the_floor_is_lava
|
||||||
import movements/the_floor_is_lava_ring
|
import movements/the_floor_is_lava_ring
|
||||||
import movement_harness/virtual_bodies as mvb
|
import movement_harness/virtual_bodies as mvb
|
||||||
@@ -158,6 +159,10 @@ type
|
|||||||
ramStuckTicks: int
|
ramStuckTicks: int
|
||||||
ramDurationTicks: int
|
ramDurationTicks: int
|
||||||
ramCooldownTicks: int
|
ramCooldownTicks: int
|
||||||
|
ramDmgWindow: array[RamDamageWindow, float] ## per-turn incoming damage
|
||||||
|
ramDmgHead: int
|
||||||
|
ramDmgAccum: float ## damage accumulated this turn, flushed by the window
|
||||||
|
lastRamLogKey: string ## TR_RAM_LOG change detector (state|reason)
|
||||||
moveTracker: VirtualBodyTracker
|
moveTracker: VirtualBodyTracker
|
||||||
virtualHits: int
|
virtualHits: int
|
||||||
virtualMiss: int
|
virtualMiss: int
|
||||||
@@ -502,6 +507,7 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
|||||||
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
||||||
|
bot.ramDmgAccum += e.bullet.power # feeds the ram bullet-rain abort window
|
||||||
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
|
bot.moveTracker.registerHit(e.bullet.power, e.bullet.direction, getX(), getY())
|
||||||
|
|
||||||
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||||
@@ -640,6 +646,10 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
|||||||
bot.ramStuckTicks = 0
|
bot.ramStuckTicks = 0
|
||||||
bot.ramDurationTicks = 0
|
bot.ramDurationTicks = 0
|
||||||
bot.ramCooldownTicks = 0
|
bot.ramCooldownTicks = 0
|
||||||
|
for i in 0 ..< RamDamageWindow: bot.ramDmgWindow[i] = 0.0
|
||||||
|
bot.ramDmgHead = 0
|
||||||
|
bot.ramDmgAccum = 0.0
|
||||||
|
bot.lastRamLogKey = ""
|
||||||
bot.hasContact = false
|
bot.hasContact = false
|
||||||
bot.tick = 0
|
bot.tick = 0
|
||||||
bot.currentGun = -1
|
bot.currentGun = -1
|
||||||
@@ -704,6 +714,32 @@ proc shouldSwitchTarget(bot: ModularBot, candidateId: int): bool =
|
|||||||
if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity
|
if cand.energy < 10.0 and cand.energy < getEnergy(): return true # finisher opportunity
|
||||||
return false
|
return false
|
||||||
|
|
||||||
|
proc pushRamDamageWindow(bot: ModularBot) =
|
||||||
|
## Advance the incoming-damage window once per turn. Damage accumulated by
|
||||||
|
## `onHitByBullet` during the preceding event dispatch lands in the slot being
|
||||||
|
## written. Called before the contact check so the window always advances.
|
||||||
|
bot.ramDmgWindow[bot.ramDmgHead] = bot.ramDmgAccum
|
||||||
|
bot.ramDmgHead = (bot.ramDmgHead + 1) mod RamDamageWindow
|
||||||
|
bot.ramDmgAccum = 0.0
|
||||||
|
|
||||||
|
proc logRamChange(bot: ModularBot, on: bool, reason: RamReason,
|
||||||
|
dist: float, ws: WorldState, dmgRate: float,
|
||||||
|
offReason = "") =
|
||||||
|
## TR_RAM_LOG: ONE `[ram]` line per state/reason CHANGE (never per tick). The
|
||||||
|
## cap is 3.0 by construction while ramming — `applyPowerPolicy`'s ram
|
||||||
|
## exemption returns `cap: 3.0`; the ring band is `[0,50]` on the same
|
||||||
|
## `shouldRam` flag. Pure observability; no effect when TR_RAM_LOG is unset.
|
||||||
|
if not RamLog: return
|
||||||
|
let key = if on: "ON|" & reasonName(reason) else: "OFF|" & offReason
|
||||||
|
if key == bot.lastRamLogKey: return
|
||||||
|
bot.lastRamLogKey = key
|
||||||
|
if on:
|
||||||
|
echo fmt"[ram] ON reason={reasonName(reason)} dist={dist.int} " &
|
||||||
|
fmt"selfE={ws.selfEnergy.int} enemyE={ws.enemyEnergy.int} cap=3.0 " &
|
||||||
|
"band=[0,50]"
|
||||||
|
else:
|
||||||
|
echo fmt"[ram] OFF reason={offReason} dmgRate={dmgRate:.2f}/turn"
|
||||||
|
|
||||||
method run*(bot: ModularBot) =
|
method run*(bot: ModularBot) =
|
||||||
while isRunning():
|
while isRunning():
|
||||||
inc bot.tick
|
inc bot.tick
|
||||||
@@ -716,6 +752,10 @@ method run*(bot: ModularBot) =
|
|||||||
if bot.ramCooldownTicks > 0:
|
if bot.ramCooldownTicks > 0:
|
||||||
dec bot.ramCooldownTicks
|
dec bot.ramCooldownTicks
|
||||||
|
|
||||||
|
# Advance the incoming-damage window once per turn (hits accumulated by
|
||||||
|
# onHitByBullet during the preceding dispatch).
|
||||||
|
bot.pushRamDamageWindow()
|
||||||
|
|
||||||
# Safety: reset invalid target before any logic
|
# Safety: reset invalid target before any logic
|
||||||
if bot.currentTargetId >= 0:
|
if bot.currentTargetId >= 0:
|
||||||
if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or
|
if not bot.enemyTracker.enemies.contains(bot.currentTargetId) or
|
||||||
@@ -750,27 +790,42 @@ method run*(bot: ModularBot) =
|
|||||||
bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y)
|
bot.enemyBearing = directionTo(getX(), getY(), tgt.x, tgt.y)
|
||||||
bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy)
|
bot.lastState = bot.buildState(tgt.x, tgt.y, tgt.speed, tgt.heading, tgt.energy)
|
||||||
|
|
||||||
# Ram decision — harness decides, not the movement module (uses fresh lastState)
|
# 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.
|
||||||
let ws = bot.lastState
|
let ws = bot.lastState
|
||||||
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
|
let ramDist = hypot(ws.enemyX - ws.selfX, ws.enemyY - ws.selfY)
|
||||||
let ramFinisher = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 300.0 and ws.enemyEnergy < 20.0 and ws.selfEnergy > ws.enemyEnergy
|
let ramDmgRate = damageRatePerTurn(bot.ramDmgWindow)
|
||||||
let ramOpportunity = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ramDist < 50.0 and ws.selfEnergy > ws.enemyEnergy + 30.0
|
# Change-of-plan signal: the selected gun's pooled virtual hit rate. Only
|
||||||
let ramDesperation = bot.ramCooldownTicks == 0 and ws.enemyEnergy > 0 and ws.selfEnergy < 5.0 and ws.enemyEnergy < 5.0 and ramDist < 150.0
|
# consulted when the (default-off) plan trigger is enabled.
|
||||||
let shouldRam = bot.currentTargetId >= 0 and
|
var gunHitRate = 0.0
|
||||||
(ramFinisher or ramOpportunity or ramDesperation)
|
if RamPlanEnabled and bot.currentGun >= 0 and tid >= 0:
|
||||||
|
gunHitRate = vb.gunRate(bot.tracker.fitnessFor(tid)[bot.currentGun], pooled = true)
|
||||||
|
let ramReason = ramTrigger(RamInputs(
|
||||||
|
dist: ramDist, selfEnergy: ws.selfEnergy,
|
||||||
|
enemyEnergy: ws.enemyEnergy, gunHitRate: gunHitRate))
|
||||||
|
let triggerRam = bot.currentTargetId >= 0 and bot.ramCooldownTicks == 0 and
|
||||||
|
ramReason != rrNone
|
||||||
|
# Abort an IN-PROGRESS ram on sustained incoming fire. The trigger itself is
|
||||||
|
# unaffected, so a firefight before the approach never vetoes the start.
|
||||||
|
let abortRam = shouldAbortRam(bot.isRamming, ramDmgRate)
|
||||||
|
let shouldRam = triggerRam and not abortRam
|
||||||
|
|
||||||
if shouldRam and not bot.isRamming:
|
if shouldRam and not bot.isRamming:
|
||||||
bot.isRamming = true
|
bot.isRamming = true
|
||||||
bot.ramStuckTicks = 0
|
bot.ramStuckTicks = 0
|
||||||
bot.ramDurationTicks = 0
|
bot.ramDurationTicks = 0
|
||||||
if ramFinisher:
|
elif abortRam and bot.isRamming:
|
||||||
echo "[ram:enter] reason=finisher dist=", ramDist.int, " selfEnergy=", ws.selfEnergy.int, " enemyEnergy=", ws.enemyEnergy.int
|
# Bullet rain: drop the ram, return to the normal range band, KEEP the
|
||||||
elif ramOpportunity:
|
# target. The cooldown stops an immediate re-trigger while fire persists.
|
||||||
echo "[ram:enter] reason=opportunity dist=", ramDist.int, " energyAdv=", (ws.selfEnergy - ws.enemyEnergy).int
|
bot.isRamming = false
|
||||||
elif ramDesperation:
|
bot.ramStuckTicks = 0
|
||||||
echo "[ram:enter] reason=desperation selfEnergy=", ws.selfEnergy.int
|
bot.ramDurationTicks = 0
|
||||||
|
bot.ramCooldownTicks = 30
|
||||||
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "bulletRain")
|
||||||
elif not shouldRam and bot.isRamming:
|
elif not shouldRam and bot.isRamming:
|
||||||
bot.isRamming = false
|
bot.isRamming = false
|
||||||
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "triggerGone")
|
||||||
|
|
||||||
if bot.isRamming:
|
if bot.isRamming:
|
||||||
inc bot.ramDurationTicks
|
inc bot.ramDurationTicks
|
||||||
@@ -784,17 +839,24 @@ method run*(bot: ModularBot) =
|
|||||||
bot.ramStuckTicks = 0
|
bot.ramStuckTicks = 0
|
||||||
bot.ramDurationTicks = 0
|
bot.ramDurationTicks = 0
|
||||||
bot.ramCooldownTicks = 30
|
bot.ramCooldownTicks = 30
|
||||||
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "stuck")
|
||||||
elif bot.ramDurationTicks > 60:
|
elif bot.ramDurationTicks > 60:
|
||||||
bot.isRamming = false
|
bot.isRamming = false
|
||||||
bot.currentTargetId = -1
|
bot.currentTargetId = -1
|
||||||
bot.ramStuckTicks = 0
|
bot.ramStuckTicks = 0
|
||||||
bot.ramDurationTicks = 0
|
bot.ramDurationTicks = 0
|
||||||
bot.ramCooldownTicks = 30
|
bot.ramCooldownTicks = 30
|
||||||
|
bot.logRamChange(false, rrNone, ramDist, ws, ramDmgRate, "duration")
|
||||||
|
|
||||||
|
# TR_RAM_LOG: the change detector dedupes on (state|reason), so this emits
|
||||||
|
# once per transition — including a reason change while already ramming.
|
||||||
|
if bot.isRamming:
|
||||||
|
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
|
||||||
|
|
||||||
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
|
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
|
||||||
# ramming through the single ring engine (ram is just band [0, 50]); the ram
|
# ramming through the single ring engine (ram is just band [0, 50]);
|
||||||
# DECISION above is unchanged. The default `tfil` keeps the exact old
|
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
|
||||||
# two-engine behaviour (tfil when holding, rammer when ramming).
|
# keeps the old two-engine behaviour (tfil when holding, rammer when ramming).
|
||||||
var spd, tr: float
|
var spd, tr: float
|
||||||
if MovementName == "tfil_ring":
|
if MovementName == "tfil_ring":
|
||||||
bot.ringMover.band =
|
bot.ringMover.band =
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
## Ram decision — the PURE trigger + abort logic for proactive ramming.
|
||||||
|
##
|
||||||
|
## ── Why this exists ─────────────────────────────────────────────────────────
|
||||||
|
## 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.
|
||||||
|
##
|
||||||
|
## 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.
|
||||||
|
##
|
||||||
|
## ── 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).
|
||||||
|
##
|
||||||
|
## ── 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
|
||||||
|
## incoming damage rate exceeds this /turn
|
||||||
|
## 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
|
||||||
|
## TR_RAM_PLAN_HITRATE default 0.05 selected gun's pooled virtual hit rate
|
||||||
|
## below which the gun duel counts as failing
|
||||||
|
## TR_RAM_LOG=1 emit one change-gated `[ram]` line
|
||||||
|
|
||||||
|
import std/[os, strutils]
|
||||||
|
|
||||||
|
proc getEnvFloat(name: string, default: float): float =
|
||||||
|
let s = getEnv(name, "")
|
||||||
|
if s.len == 0: return default
|
||||||
|
try: result = parseFloat(s.strip())
|
||||||
|
except ValueError: result = default
|
||||||
|
|
||||||
|
proc getEnvBool(name: string, default: bool): bool =
|
||||||
|
let s = getEnv(name, "").strip().toLowerAscii()
|
||||||
|
if s.len == 0: return default
|
||||||
|
s in ["1", "true", "yes", "on"]
|
||||||
|
|
||||||
|
const
|
||||||
|
## Finisher / desperation keep their original gates: the finisher is already
|
||||||
|
## proactive, and the desperation case is a last-ditch, short-range play.
|
||||||
|
RamFinisherDist* = 300.0
|
||||||
|
RamFinisherEnergy* = 20.0
|
||||||
|
RamDesperationDist* = 150.0
|
||||||
|
RamDesperationEnergy* = 5.0
|
||||||
|
## Number of turns the incoming-damage window averages over. 15 turns ≈ 0.75s
|
||||||
|
## at 20 turns/s: short enough to react to a burst, long enough that a single
|
||||||
|
## stray hit does not abort the approach.
|
||||||
|
RamDamageWindow* = 15
|
||||||
|
## Shipped defaults for the env-overridable knobs.
|
||||||
|
DefaultRamOppDist* = 200.0
|
||||||
|
DefaultRamOppMargin* = 15.0
|
||||||
|
DefaultRamAbortDmg* = 0.5
|
||||||
|
DefaultRamPlanDist* = 250.0
|
||||||
|
DefaultRamPlanMargin* = 20.0
|
||||||
|
DefaultRamPlanHitRate* = 0.05
|
||||||
|
|
||||||
|
let RamOppDist* = getEnvFloat("TR_RAM_OPP_DIST", DefaultRamOppDist)
|
||||||
|
let RamOppMargin* = getEnvFloat("TR_RAM_OPP_MARGIN", DefaultRamOppMargin)
|
||||||
|
let RamAbortDmg* = getEnvFloat("TR_RAM_ABORT_DMG", DefaultRamAbortDmg)
|
||||||
|
let RamPlanEnabled* = getEnvBool("TR_RAM_PLAN", false)
|
||||||
|
let RamPlanDist* = getEnvFloat("TR_RAM_PLAN_DIST", DefaultRamPlanDist)
|
||||||
|
let RamPlanMargin* = getEnvFloat("TR_RAM_PLAN_MARGIN", DefaultRamPlanMargin)
|
||||||
|
let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate)
|
||||||
|
let RamLog* = existsEnv("TR_RAM_LOG")
|
||||||
|
|
||||||
|
type
|
||||||
|
RamReason* = enum
|
||||||
|
rrNone ## no trigger fires
|
||||||
|
rrFinisher ## enemy < 20 energy, we are healthier, dist < 300
|
||||||
|
rrOpportunity ## we clearly out-energise and are close enough to close
|
||||||
|
rrDesperation ## both nearly dead, short range
|
||||||
|
rrPlan ## change of plan: out-energise, close, gun duel failing
|
||||||
|
|
||||||
|
RamInputs* = object
|
||||||
|
dist*: float
|
||||||
|
selfEnergy*: float
|
||||||
|
enemyEnergy*: float
|
||||||
|
## The selected gun's pooled virtual hit rate (0..1). Only consulted by the
|
||||||
|
## change-of-plan trigger; ignored when that trigger is disabled.
|
||||||
|
gunHitRate*: float
|
||||||
|
|
||||||
|
proc ramTrigger*(inp: RamInputs,
|
||||||
|
oppDist = RamOppDist,
|
||||||
|
oppMargin = RamOppMargin,
|
||||||
|
planEnabled = RamPlanEnabled,
|
||||||
|
planDist = RamPlanDist,
|
||||||
|
planMargin = RamPlanMargin,
|
||||||
|
planHitRate = RamPlanHitRate): RamReason =
|
||||||
|
## Pure trigger evaluation. Returns the FIRST matching reason in priority
|
||||||
|
## 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.
|
||||||
|
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:
|
||||||
|
return rrOpportunity
|
||||||
|
if inp.selfEnergy < RamDesperationEnergy and inp.enemyEnergy < RamDesperationEnergy and
|
||||||
|
inp.dist < RamDesperationDist:
|
||||||
|
return rrDesperation
|
||||||
|
if planEnabled and inp.dist < planDist and
|
||||||
|
inp.selfEnergy > inp.enemyEnergy + planMargin and
|
||||||
|
inp.gunHitRate < planHitRate:
|
||||||
|
return rrPlan
|
||||||
|
rrNone
|
||||||
|
|
||||||
|
proc reasonName*(r: RamReason): string =
|
||||||
|
case r
|
||||||
|
of rrNone: "none"
|
||||||
|
of rrFinisher: "finisher"
|
||||||
|
of rrOpportunity: "opportunity"
|
||||||
|
of rrDesperation: "desperation"
|
||||||
|
of rrPlan: "plan"
|
||||||
|
|
||||||
|
proc damageRatePerTurn*(window: openArray[float]): float =
|
||||||
|
## Mean incoming damage per turn over the window. Pure; an empty or all-zero
|
||||||
|
## window returns 0.0 (never NaN).
|
||||||
|
if window.len == 0: return 0.0
|
||||||
|
var total = 0.0
|
||||||
|
for v in window: total += v
|
||||||
|
total / window.len.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).
|
||||||
|
ramming and dmgRate > abortDmg
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
## 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 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.
|
||||||
|
|
||||||
|
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 defaults are the RELAXED gates ────────────────────────────
|
||||||
|
|
||||||
|
proc testDefaults() =
|
||||||
|
check "default opportunity distance is relaxed to 200 (was 50)",
|
||||||
|
DefaultRamOppDist == 200.0
|
||||||
|
check "default opportunity margin is relaxed to 15 (was 30)",
|
||||||
|
DefaultRamOppMargin == 15.0
|
||||||
|
check "default bullet-rain abort threshold is 0.5/turn",
|
||||||
|
DefaultRamAbortDmg == 0.5
|
||||||
|
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) ────────
|
||||||
|
|
||||||
|
proc testOpportunityAtClosableRange() =
|
||||||
|
# The exact shape the diagnosis used: dist 143, we are clearly healthier.
|
||||||
|
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 "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
|
||||||
|
|
||||||
|
# ── 3. the energy margin is env-tunable and enforced ─────────────────────────
|
||||||
|
|
||||||
|
proc testMargin() =
|
||||||
|
# 9-energy lead is below the default 15 margin, so no opportunity...
|
||||||
|
let thin = RamInputs(dist: 143.0, selfEnergy: 50.0, enemyEnergy: 41.0)
|
||||||
|
check "9-energy lead does NOT trigger at the default margin",
|
||||||
|
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 "37-energy lead vetoed by a 40-energy margin",
|
||||||
|
ramTrigger(RamInputs(dist: 143.0, selfEnergy: 78.0, enemyEnergy: 41.0),
|
||||||
|
oppMargin = 40.0) == rrNone
|
||||||
|
|
||||||
|
# ── 4. finisher + desperation keep working 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 "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. 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
|
||||||
|
|
||||||
|
proc testAbort() =
|
||||||
|
check "no abort before the ram has started",
|
||||||
|
not shouldAbortRam(ramming = false, dmgRate = 5.0)
|
||||||
|
check "no abort at exactly the threshold",
|
||||||
|
not shouldAbortRam(ramming = true, dmgRate = 0.5)
|
||||||
|
check "abort above the threshold",
|
||||||
|
shouldAbortRam(ramming = true, dmgRate = 0.51)
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
testOpportunityAtClosableRange()
|
||||||
|
testMargin()
|
||||||
|
testFinisherAndDesperation()
|
||||||
|
testPlanTrigger()
|
||||||
|
testDamageRate()
|
||||||
|
testAbort()
|
||||||
|
testReasonNames()
|
||||||
|
|
||||||
|
if failures > 0:
|
||||||
|
echo "\n", failures, " check(s) FAILED"
|
||||||
|
quit(1)
|
||||||
|
echo "\nAll ram-decision checks passed."
|
||||||
Reference in New Issue
Block a user