STRAFE movement: body pinned perpendicular to the threat, reversals by sign flip

New engine movements/strafe.nim, selected by TR_MOVEMENT=strafe (default stays
tfil, byte-identical — test_tfil_commit_env.nim's 30 checks still pass).

Design (the owner's):
- AXIS = incoming bullet's direction when a bullet is in flight, else the
  perpendicular of the enemy bearing. The body heading is kept inside a band
  (TR_STRAFE_BAND, default 20 deg) around the perpendicular LINE; it turns only
  when outside the band, and never turns to face a movement target.
- Candidate tiles on the perpendicular line through our position, both forward
  and backward, within TR_STRAFE_REACH px, with a perpendicular jitter of
  +/- TR_STRAFE_SPREAD tiles. A tile is acceptable when its path max heat is
  <= PathDangerThreshold, the SAME safety rule TFIL uses.
- Move by SIGN only: setForward(+/-MaxSpeed>). Dwell is re-picked after a random
  number of ticks in [TR_STRAFE_DWELL_MIN, TR_STRAFE_DWELL_MAX], on arrival, or
  on a serious threat spike.
- Heat machinery is REUSED from the shipped mover, not re-implemented: the
  exported heatDecay()/bulletMagScale() (j105 time-indexed model) and the
  PillarHotness/PillarRadiance globals (j106 pillar-free default). The heat
  shape is overridable via TR_STRAFE_CORRIDOR_HEAT/WALL_HOTNESS/WALL_RADIANCE
  (defaults = the shipped TFIL field).
- GUI overlay: strafe line, threat axis, candidate tiles (safe/unsafe), chosen
  target, sign-coloured movement ray, and the heading band.

Gates (offline, recorded DrussGT fixture, 20026 ticks):
- A TILE AVAILABILITY: shipped heat field -> a safe tile exists on only 36.6%
  of picks (63.4% fall back to the least-hot tile); the ring retune
  (corridor 5, wall 10/5) raises it to 91.9%.
- B PREDICTABILITY: reversal-interval entropy 5.84 bits vs TFIL 5.09; direction
  entropy 1.00 both; long-lag autocorrelation ~0 for both (no periodic
  component). Fewer reversals (710 vs 1453) and more full-speed ticks.
  measurements: common_libs/tests/measure_strafe_gates.nim

Also registers TR_STRAFE_* in the boot env report (ModularBot_garage/src/
env_report.nim) and wires the engine into ModularBot.nim (hold -> strafe,
ram trigger -> rammer).
This commit is contained in:
2026-09-25 22:38:52 +02:00
parent 99cf9e5c82
commit a50c0125d5
4 changed files with 1103 additions and 9 deletions
+17 -6
View File
@@ -33,6 +33,7 @@ import movements/rammer
import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movements/strafe
import movement_harness/virtual_bodies as mvb
import movement_harness/bullet_shadows
import targeting/enemy_tracker
@@ -193,6 +194,7 @@ type
bitbrain: BitBrainGun
mover: TFILModule
ringMover: TFILRingModule
strafeMover: StrafeModule
rammer: RammerModule
isRamming: bool
ramDurationTicks: int
@@ -317,7 +319,9 @@ proc printConfig(bot: ModularBot, forceAll: bool = false) =
let tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: ""
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
let moveName =
if MovementName == "tfil_ring":
if MovementName == "strafe":
if bot.isRamming: "strafe(ram)" else: "strafe"
elif MovementName == "tfil_ring":
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
elif bot.isRamming: "rammer"
else: "tfil"
@@ -567,6 +571,7 @@ method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
@@ -574,6 +579,7 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
bot.mover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
@@ -735,6 +741,7 @@ method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
bot.meleeRadar.init()
bot.mover.resetRound()
bot.ringMover.resetRound()
bot.strafeMover.resetRound()
bot.enemyTracker.resetRound()
# The TM pattern gun is cold every round (no cross-battle persistence): wipe
# its clause teams and motion history explicitly, so a same-id opponent in the
@@ -971,12 +978,15 @@ method run*(bot: ModularBot) =
if bot.isRamming:
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
# ramming through the single ring engine (ram is just band [0, 50]);
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
# keeps the old two-engine behaviour (tfil when holding, rammer when ramming).
# Movement dispatch. `TR_MOVEMENT=strafe` (this module) routes holding
# through the perpendicular-strafe engine and still lets the rammer take
# over on a ram trigger; `tfil_ring` routes BOTH holding and ramming
# through the single ring engine; the default `tfil` keeps the old
# two-engine behaviour (tfil when holding, rammer when ramming).
var spd, tr: float
if MovementName == "tfil_ring":
if MovementName == "strafe" and not shouldRam:
(spd, tr) = bot.strafeMover.computeMove(ws)
elif MovementName == "tfil_ring":
bot.ringMover.band =
if shouldRam: (lo: 0.0, hi: 50.0)
else: (lo: RangeLo, hi: RangeHi)
@@ -1280,6 +1290,7 @@ when isMainModule:
meleeRadar: initAdaptiveMeleeRadar(),
mover: TFILModule(debugGraphics: true),
ringMover: TFILRingModule(debugGraphics: true),
strafeMover: StrafeModule(debugGraphics: true),
rammer: initRammer(),
moveTracker: mvb.initVirtualBodyTracker(1),
currentGun: -1,
+24 -3
View File
@@ -25,6 +25,7 @@ import gun_harness/selector
import movements/ram_decision
import movements/the_floor_is_lava
import movements/the_floor_is_lava_ring
import movements/strafe
import guns/tm_horizon
import guns/bitbrain_gun
import guns/pattern_matcher
@@ -174,9 +175,12 @@ proc printEffectiveValues(ctx: EnvReportContext) =
echo "[env] --- B. effective values (resolved at boot) ---"
# ── movement / process-level ModularBot knobs ─────────────────────────────
# Only `tfil_ring` selects the ring engine; every other value runs `tfil`, so
# report the RESOLVED engine rather than the raw string.
let effectiveMovement = if ctx.movementName == "tfil_ring": "tfil_ring" else: "tfil"
# Only `tfil_ring` and `strafe` select a non-default engine; every other value
# runs `tfil`, so report the RESOLVED engine rather than the raw string.
let effectiveMovement =
if ctx.movementName == "tfil_ring": "tfil_ring"
elif ctx.movementName == "strafe": "strafe"
else: "tfil"
emit("TR_MOVEMENT", effectiveMovement, sourceOf("TR_MOVEMENT"))
emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG"))
emit("TR_VBULLET_ADMIT_ONLY", onOff(ctx.vBulletAdmitOnly),
@@ -259,6 +263,19 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_TFIL_PILLAR_ON", onOff(PillarHotness > 0.0),
sourceOf("TR_TFIL_PILLAR_ON"))
# ── movement (STRAFE) ─────────────────────────────────────────────────────
emit("TR_STRAFE_BAND", $StrafeBand, sourceOf("TR_STRAFE_BAND"))
emit("TR_STRAFE_SPREAD", $StrafeSpread, sourceOf("TR_STRAFE_SPREAD"))
emit("TR_STRAFE_REACH", $StrafeReach, sourceOf("TR_STRAFE_REACH"))
emit("TR_STRAFE_DWELL_MIN", $StrafeDwellMin, sourceOf("TR_STRAFE_DWELL_MIN"))
emit("TR_STRAFE_DWELL_MAX", $StrafeDwellMax, sourceOf("TR_STRAFE_DWELL_MAX"))
emit("TR_STRAFE_LOG", onOff(StrafeLog), sourceOfPresence("TR_STRAFE_LOG"))
# STRAFE's heat shape is override-able (default = the shipped TFIL field) so
# the shipped field and the ring retune can be A/B'd on one binary at boot.
emit("TR_STRAFE_CORRIDOR_HEAT", $StrafeCorridorHeat, sourceOf("TR_STRAFE_CORRIDOR_HEAT"))
emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS"))
emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE"))
# ── ramming ───────────────────────────────────────────────────────────────
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
emit("TR_RAM_OPP_DIST", $RamOppDist, sourceOf("TR_RAM_OPP_DIST"))
@@ -409,6 +426,10 @@ proc knownEnvNames*(): seq[string] =
"TR_TFIL_COMMIT_LOG",
"TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN",
"TR_TFIL_PILLAR_ON",
"TR_STRAFE_BAND", "TR_STRAFE_SPREAD", "TR_STRAFE_REACH",
"TR_STRAFE_DWELL_MIN", "TR_STRAFE_DWELL_MAX", "TR_STRAFE_LOG",
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
"TR_STRAFE_WALL_RADIANCE",
# harness vars (read by the test framework, inherited by the bot, so they
# must NOT be reported as typos)
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",