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:
@@ -33,6 +33,7 @@ import movements/rammer
|
|||||||
import movements/ram_decision
|
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 movements/strafe
|
||||||
import movement_harness/virtual_bodies as mvb
|
import movement_harness/virtual_bodies as mvb
|
||||||
import movement_harness/bullet_shadows
|
import movement_harness/bullet_shadows
|
||||||
import targeting/enemy_tracker
|
import targeting/enemy_tracker
|
||||||
@@ -193,6 +194,7 @@ type
|
|||||||
bitbrain: BitBrainGun
|
bitbrain: BitBrainGun
|
||||||
mover: TFILModule
|
mover: TFILModule
|
||||||
ringMover: TFILRingModule
|
ringMover: TFILRingModule
|
||||||
|
strafeMover: StrafeModule
|
||||||
rammer: RammerModule
|
rammer: RammerModule
|
||||||
isRamming: bool
|
isRamming: bool
|
||||||
ramDurationTicks: int
|
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 tc = if bot.currentTargetId != bot.prevTarget or forceAll: CLR_CHANGE else: ""
|
||||||
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
|
let radarName = if bot.radarMode == 0: "radar_lock" else: "adaptive_melee"
|
||||||
let moveName =
|
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"
|
if bot.isRamming: "tfil_ring(ram)" else: "tfil_ring"
|
||||||
elif bot.isRamming: "rammer"
|
elif bot.isRamming: "rammer"
|
||||||
else: "tfil"
|
else: "tfil"
|
||||||
@@ -567,6 +571,7 @@ method onBulletHitWall*(bot: ModularBot, e: BulletHitWallEvent) =
|
|||||||
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
bot.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
bot.mover.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.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
|
||||||
discard bot.resolveOwnBullet(e.bullet.bulletId) # bullet-vs-bullet: free the slot
|
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.moveTracker.shadows.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
bot.mover.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.ringMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
bot.strafeMover.removeBulletNear(e.bullet.x, e.bullet.y)
|
||||||
|
|
||||||
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
|
||||||
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
|
# 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.meleeRadar.init()
|
||||||
bot.mover.resetRound()
|
bot.mover.resetRound()
|
||||||
bot.ringMover.resetRound()
|
bot.ringMover.resetRound()
|
||||||
|
bot.strafeMover.resetRound()
|
||||||
bot.enemyTracker.resetRound()
|
bot.enemyTracker.resetRound()
|
||||||
# The TM pattern gun is cold every round (no cross-battle persistence): wipe
|
# 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
|
# 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:
|
if bot.isRamming:
|
||||||
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
|
bot.logRamChange(true, ramReason, ramDist, ws, ramDmgRate)
|
||||||
|
|
||||||
# Movement dispatch. `TR_MOVEMENT=tfil_ring` routes BOTH holding and
|
# Movement dispatch. `TR_MOVEMENT=strafe` (this module) routes holding
|
||||||
# ramming through the single ring engine (ram is just band [0, 50]);
|
# through the perpendicular-strafe engine and still lets the rammer take
|
||||||
# `shouldRam` already accounts for the bullet-rain abort. The default `tfil`
|
# over on a ram trigger; `tfil_ring` routes BOTH holding and ramming
|
||||||
# keeps the old two-engine behaviour (tfil when holding, rammer when 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
|
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 =
|
bot.ringMover.band =
|
||||||
if shouldRam: (lo: 0.0, hi: 50.0)
|
if shouldRam: (lo: 0.0, hi: 50.0)
|
||||||
else: (lo: RangeLo, hi: RangeHi)
|
else: (lo: RangeLo, hi: RangeHi)
|
||||||
@@ -1280,6 +1290,7 @@ when isMainModule:
|
|||||||
meleeRadar: initAdaptiveMeleeRadar(),
|
meleeRadar: initAdaptiveMeleeRadar(),
|
||||||
mover: TFILModule(debugGraphics: true),
|
mover: TFILModule(debugGraphics: true),
|
||||||
ringMover: TFILRingModule(debugGraphics: true),
|
ringMover: TFILRingModule(debugGraphics: true),
|
||||||
|
strafeMover: StrafeModule(debugGraphics: true),
|
||||||
rammer: initRammer(),
|
rammer: initRammer(),
|
||||||
moveTracker: mvb.initVirtualBodyTracker(1),
|
moveTracker: mvb.initVirtualBodyTracker(1),
|
||||||
currentGun: -1,
|
currentGun: -1,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import gun_harness/selector
|
|||||||
import movements/ram_decision
|
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 movements/strafe
|
||||||
import guns/tm_horizon
|
import guns/tm_horizon
|
||||||
import guns/bitbrain_gun
|
import guns/bitbrain_gun
|
||||||
import guns/pattern_matcher
|
import guns/pattern_matcher
|
||||||
@@ -174,9 +175,12 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
|||||||
echo "[env] --- B. effective values (resolved at boot) ---"
|
echo "[env] --- B. effective values (resolved at boot) ---"
|
||||||
|
|
||||||
# ── movement / process-level ModularBot knobs ─────────────────────────────
|
# ── movement / process-level ModularBot knobs ─────────────────────────────
|
||||||
# Only `tfil_ring` selects the ring engine; every other value runs `tfil`, so
|
# Only `tfil_ring` and `strafe` select a non-default engine; every other value
|
||||||
# report the RESOLVED engine rather than the raw string.
|
# runs `tfil`, so report the RESOLVED engine rather than the raw string.
|
||||||
let effectiveMovement = if ctx.movementName == "tfil_ring": "tfil_ring" else: "tfil"
|
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", effectiveMovement, sourceOf("TR_MOVEMENT"))
|
||||||
emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG"))
|
emit("TR_MOVEMENT_LOG", onOff(MovementLog), sourceOfPresence("TR_MOVEMENT_LOG"))
|
||||||
emit("TR_VBULLET_ADMIT_ONLY", onOff(ctx.vBulletAdmitOnly),
|
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),
|
emit("TR_TFIL_PILLAR_ON", onOff(PillarHotness > 0.0),
|
||||||
sourceOf("TR_TFIL_PILLAR_ON"))
|
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 ───────────────────────────────────────────────────────────────
|
# ── ramming ───────────────────────────────────────────────────────────────
|
||||||
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
|
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
|
||||||
emit("TR_RAM_OPP_DIST", $RamOppDist, sourceOf("TR_RAM_OPP_DIST"))
|
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_COMMIT_LOG",
|
||||||
"TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN",
|
"TR_TFIL_HEAT_TIME", "TR_TFIL_HEAT_TAU", "TR_TFIL_HEAT_POWER_GAIN",
|
||||||
"TR_TFIL_PILLAR_ON",
|
"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
|
# harness vars (read by the test framework, inherited by the bot, so they
|
||||||
# must NOT be reported as typos)
|
# must NOT be reported as typos)
|
||||||
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
||||||
|
|||||||
@@ -0,0 +1,740 @@
|
|||||||
|
## STRAFE — body pinned perpendicular to the threat; reversals by SIGN FLIP.
|
||||||
|
##
|
||||||
|
## ── The idea (the owner's design) ───────────────────────────────────────────
|
||||||
|
## TFIL changes its left<->right direction by TURNING the body. Job j85 measured
|
||||||
|
## the cost of that turn: the speed at the reversal tick drops to ~0.6 px/tick
|
||||||
|
## (from ~6.4), takes 7-8 ticks to recover, and the hit rate on reversal ticks
|
||||||
|
## is 1.40x baseline (peaking 1.84x at 6-10 ticks) — the shipped mover also has
|
||||||
|
## the LOWEST mean speed of the arms tested (4.69 vs 5.37 px/tick).
|
||||||
|
##
|
||||||
|
## In Tank Royale the backward speed EQUALS the forward speed (measured
|
||||||
|
## +8.000 / -8.000 over 75,518 ticks), so a reversal is free if it is done by
|
||||||
|
## flipping the sign of `setForward` instead of turning the hull around. STRAFE
|
||||||
|
## exploits exactly that:
|
||||||
|
##
|
||||||
|
## * keep the BODY pinned ~perpendicular to the threat (`threat axis` below),
|
||||||
|
## so "move left" and "move right" are both along the body axis;
|
||||||
|
## * pick a random safe tile on the perpendicular line (forward or backward)
|
||||||
|
## and move to it with `setForward(±8)`;
|
||||||
|
## * NEVER turn to face a movement target — the only turns are small
|
||||||
|
## corrections that keep the heading inside a band around the perpendicular.
|
||||||
|
##
|
||||||
|
## Bonus physics: the bot is a 36 px square. Projected width is 36 px side-on
|
||||||
|
## or head-on but 50.9 px at 45 degrees, so staying near 90 degrees avoids the
|
||||||
|
## worst orientation (up to 29% smaller target).
|
||||||
|
##
|
||||||
|
## ── Threat axis (point 1 of the spec) ───────────────────────────────────────
|
||||||
|
## When a bullet is in flight the axis is the INCOMING BULLET's direction: a
|
||||||
|
## bullet comes from where the enemy WAS when it fired, which at long range
|
||||||
|
## differs from its current position by 100+ px. When the sky is clear the axis
|
||||||
|
## falls back to the PERPENDICULAR of the enemy bearing.
|
||||||
|
##
|
||||||
|
## ── Heading band, not an exact pin (point 2) ────────────────────────────────
|
||||||
|
## The body heading is kept inside `TR_STRAFE_BAND` degrees of the perpendicular
|
||||||
|
## line (`lineDir`); the band offset is re-randomised on every pick so the
|
||||||
|
## heading is not a constant. Only turns when OUTSIDE the band, and turns the
|
||||||
|
## short way (the folded deviation is in [-90, 90]).
|
||||||
|
##
|
||||||
|
## ── Candidate tiles (point 3) ───────────────────────────────────────────────
|
||||||
|
## Tiles on the perpendicular line through the current position, both forward
|
||||||
|
## and backward, within `TR_STRAFE_REACH` px, inside the arena, with a small
|
||||||
|
## PERPENDICULAR JITTER of `±TR_STRAFE_SPREAD` tiles (the owner's "spread a
|
||||||
|
## little"). A tile is acceptable when the max heat ON THE STRAIGHT-LINE PATH
|
||||||
|
## from the bot is <= `PathDangerThreshold` (10.0) — the SAME safety rule TFIL
|
||||||
|
## uses. The heat field itself is the SHIPPED TFIL field (jobs j105/j106: the
|
||||||
|
## time-indexed bullet model + the pillar-free default) — see the reuse note
|
||||||
|
## below.
|
||||||
|
##
|
||||||
|
## ── Move by sign only (point 5) ─────────────────────────────────────────────
|
||||||
|
## `speed = MaxSpeed * sign`, where `sign` is +1 when the chosen tile lies along
|
||||||
|
## the current heading and -1 when it lies opposite. There is NO turn-to-target
|
||||||
|
## anywhere in this mover.
|
||||||
|
##
|
||||||
|
## ── Randomised dwell (point 6) ──────────────────────────────────────────────
|
||||||
|
## The target is re-picked after `rand(TR_STRAFE_DWELL_MIN .. TR_STRAFE_DWELL_MAX)`
|
||||||
|
## ticks, on arrival, or immediately when the chosen tile's heat spikes by
|
||||||
|
## `DangerReplanThreshold` (a serious threat). This is the PRIMARY anti-pattern
|
||||||
|
## defence: a periodic reversal is trivially learnable by DrussGT's pattern gun,
|
||||||
|
## so the reversal TIMING is randomised and the offline entropy gate (B) checks
|
||||||
|
## it.
|
||||||
|
##
|
||||||
|
## ── Reuse of the j105/j106 heat machinery ───────────────────────────────────
|
||||||
|
## This module does NOT re-implement the time-indexed bullet model. It imports
|
||||||
|
## `movements/the_floor_is_lava` and calls its EXPORTED `heatDecay(dt)` and
|
||||||
|
## `bulletMagScale(power)`, and reads its exported `PillarHotness` /
|
||||||
|
## `PillarRadiance` (0/0 = the shipped pillar-free default) and `TfilHeatTime`
|
||||||
|
## (for the debug corridor reach). So `TR_TFIL_HEAT_TIME` / `TR_TFIL_HEAT_TAU` /
|
||||||
|
## `TR_TFIL_HEAT_POWER_GAIN` / `TR_TFIL_PILLAR_ON` drive the STRAFE field exactly
|
||||||
|
## as they drive TFIL's. The bullet tracking, heat painting and path sampling are
|
||||||
|
## copied from the shipped mover (the same pattern `the_floor_is_lava_ring.nim`
|
||||||
|
## uses) because the shipped file must stay byte-identical and its private
|
||||||
|
## constants are not exported.
|
||||||
|
##
|
||||||
|
## ── Env knobs (all read at module init) ─────────────────────────────────────
|
||||||
|
## TR_MOVEMENT = strafe (selects this engine; default stays tfil)
|
||||||
|
## TR_STRAFE_BAND 20.0 heading band half-width (deg)
|
||||||
|
## TR_STRAFE_SPREAD 1 perpendicular jitter (tiles, ±)
|
||||||
|
## TR_STRAFE_REACH 144.0 along-line reach (px)
|
||||||
|
## TR_STRAFE_DWELL_MIN 6 min ticks before a re-pick
|
||||||
|
## TR_STRAFE_DWELL_MAX 20 max ticks before a re-pick
|
||||||
|
## TR_STRAFE_LOG off presence-based: echo one line per pick
|
||||||
|
## TR_STRAFE_CORRIDOR_HEAT 20.0 lava per corridor tile (shipped TFIL value)
|
||||||
|
## TR_STRAFE_WALL_HOTNESS 30.0 peak wall radiance (shipped TFIL value)
|
||||||
|
## TR_STRAFE_WALL_RADIANCE 10.0 wall radiance falloff (shipped TFIL value)
|
||||||
|
##
|
||||||
|
## The default `TR_MOVEMENT=tfil` path is untouched; this module is only ever
|
||||||
|
## called when the bot explicitly selects `strafe`.
|
||||||
|
|
||||||
|
import std/[math, random, os]
|
||||||
|
from std/strutils import parseFloat, parseInt, strip
|
||||||
|
import std/strformat
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
import movement_harness/movement_interface
|
||||||
|
import robocode_tankroyale_botapi/graphics
|
||||||
|
import robocode_tankroyale_botapi/color
|
||||||
|
# j105/j106 reuse: the exported time-indexed heat helpers + pillar globals.
|
||||||
|
import movements/the_floor_is_lava
|
||||||
|
|
||||||
|
const GridSize = 36.0
|
||||||
|
const MaxSpeed = 8.0
|
||||||
|
|
||||||
|
const BulletCoreRadiusMin = 9.0 ## core radius at power 0.1
|
||||||
|
const BulletCoreRadiusMax = 54.0 ## core radius at power 3.0
|
||||||
|
const BulletAuraExtMin = 36.0 ## aura extension at power 3.0 (slow)
|
||||||
|
const BulletAuraExtMax = 54.0 ## aura extension at power 0.1 (fast)
|
||||||
|
|
||||||
|
const BulletCore = 10.0 ## lava per bullet-overlapping tile (shipped default)
|
||||||
|
const BulletAura = 5.0 ## lava for aura ring tiles (shipped default)
|
||||||
|
|
||||||
|
const EnemyCoreRadius = 18.0 ## half of 36px body
|
||||||
|
const EnemyAuraRadius = 54.0 ## 18 + 36
|
||||||
|
const EnemyCore = 40.0 ## lava per tile overlapping enemy body
|
||||||
|
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
|
||||||
|
|
||||||
|
const CorridorHeatDefault = 20.0 ## shipped TFIL corridor heat
|
||||||
|
const WallHotnessDefault = 30.0 ## shipped TFIL wall radiance peak
|
||||||
|
const WallRadianceDefault = 10.0 ## shipped TFIL wall radiance falloff
|
||||||
|
|
||||||
|
## Heat shape is override-able so the two candidate fields can be compared on
|
||||||
|
## one binary (the shipped field vs the ring variant's retune). Default = the
|
||||||
|
## SHIPPED TFIL field, so the strafe mover is judged on the same field as the
|
||||||
|
## baseline. `TR_STRAFE_CORRIDOR_HEAT` / `TR_STRAFE_WALL_HOTNESS` /
|
||||||
|
## `TR_STRAFE_WALL_RADIANCE` are strafe-specific (the ring mover's own field is
|
||||||
|
## controlled by its separate `TR_TFIL_*` names).
|
||||||
|
var
|
||||||
|
StrafeCorridorHeat* = CorridorHeatDefault
|
||||||
|
StrafeWallHotness* = WallHotnessDefault
|
||||||
|
StrafeWallRadiance* = WallRadianceDefault
|
||||||
|
|
||||||
|
const PathSampleStep = 18.0 ## ~half a tile
|
||||||
|
const PathDangerThreshold = 10.0 ## max lava on path; above this = unsafe
|
||||||
|
const DangerReplanThreshold = 25.0 ## serious-threat replan (bullet core)
|
||||||
|
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
|
||||||
|
|
||||||
|
# ── Env knobs ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 getEnvInt(name: string, default: int): int =
|
||||||
|
let s = getEnv(name, "")
|
||||||
|
if s.len == 0: return default
|
||||||
|
try: result = parseInt(s.strip())
|
||||||
|
except ValueError: result = default
|
||||||
|
|
||||||
|
const
|
||||||
|
DefaultStrafeBand = 20.0
|
||||||
|
DefaultStrafeSpread = 1
|
||||||
|
DefaultStrafeReach = 144.0
|
||||||
|
DefaultStrafeDwellMin = 6
|
||||||
|
DefaultStrafeDwellMax = 20
|
||||||
|
|
||||||
|
var
|
||||||
|
StrafeBand* = DefaultStrafeBand
|
||||||
|
StrafeSpread* = DefaultStrafeSpread
|
||||||
|
StrafeReach* = DefaultStrafeReach
|
||||||
|
StrafeDwellMin* = DefaultStrafeDwellMin
|
||||||
|
StrafeDwellMax* = DefaultStrafeDwellMax
|
||||||
|
StrafeLog* = false
|
||||||
|
|
||||||
|
proc loadStrafeHeatEnv*() =
|
||||||
|
## Re-read the heat-shape overrides. Exposed so a gate can restore the shipped
|
||||||
|
## field after temporarily retuning it on the SAME process.
|
||||||
|
StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault)
|
||||||
|
StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault)
|
||||||
|
StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault)
|
||||||
|
|
||||||
|
proc loadStrafeEnv*() =
|
||||||
|
## Read the strafe knobs. Called once at module init; callable again after
|
||||||
|
## `putEnv` so a gate script can exercise the arms in one process.
|
||||||
|
StrafeBand = max(0.0, min(90.0, getEnvFloat("TR_STRAFE_BAND", DefaultStrafeBand)))
|
||||||
|
StrafeSpread = max(0, getEnvInt("TR_STRAFE_SPREAD", DefaultStrafeSpread))
|
||||||
|
StrafeReach = max(GridSize, getEnvFloat("TR_STRAFE_REACH", DefaultStrafeReach))
|
||||||
|
StrafeDwellMin = max(1, getEnvInt("TR_STRAFE_DWELL_MIN", DefaultStrafeDwellMin))
|
||||||
|
StrafeDwellMax = max(StrafeDwellMin, getEnvInt("TR_STRAFE_DWELL_MAX", DefaultStrafeDwellMax))
|
||||||
|
StrafeLog = existsEnv("TR_STRAFE_LOG")
|
||||||
|
loadStrafeHeatEnv()
|
||||||
|
|
||||||
|
loadStrafeEnv()
|
||||||
|
|
||||||
|
# ── small angle helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc wrap180(d: float): float {.inline.} =
|
||||||
|
result = d
|
||||||
|
while result > 180.0: result -= 360.0
|
||||||
|
while result < -180.0: result += 360.0
|
||||||
|
|
||||||
|
const DegToRad = PI / 180.0
|
||||||
|
|
||||||
|
# ── module types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
TrackedBullet = object
|
||||||
|
originX, originY: float
|
||||||
|
x, y: float
|
||||||
|
velX, velY: float ## speed * cos(heading), speed * sin(heading)
|
||||||
|
power: float
|
||||||
|
alive: bool
|
||||||
|
age: int
|
||||||
|
|
||||||
|
StrafeModule* = object
|
||||||
|
debugGraphics*: bool
|
||||||
|
cols*, rows*: int
|
||||||
|
marginX*, marginY*: float
|
||||||
|
arenaWidth*, arenaHeight*: float
|
||||||
|
lava: seq[float]
|
||||||
|
bullets: seq[TrackedBullet]
|
||||||
|
prevEnergy: seq[tuple[id: int, energy: float]]
|
||||||
|
# ── decision state ──
|
||||||
|
targetX*, targetY*: float ## chosen tile centre (world coords)
|
||||||
|
targetValid*: bool
|
||||||
|
targetLava: float ## heat at the chosen tile when picked
|
||||||
|
dwell*: int ## ticks remaining on the current target
|
||||||
|
dir*: float ## commanded sign: +1 forward, -1 backward
|
||||||
|
bandOffset: float ## random in [-band, band], re-rolled per pick
|
||||||
|
lineDir*: float ## undirected strafe line bearing (deg)
|
||||||
|
# ── diagnostics (gate B + GUI) ──
|
||||||
|
callCount*: int
|
||||||
|
picks*: int
|
||||||
|
lastPickCall*: int ## callCount at the last pick (interval source)
|
||||||
|
lastCandCount*: int ## candidates generated at the last pick
|
||||||
|
lastSafeCount*: int ## of those, path-safe at the last pick
|
||||||
|
fallbackPicks*: int ## picks where the safe pool was empty
|
||||||
|
reversals*: int ## sign flips of the commanded direction
|
||||||
|
lineDirFlips*: int ## times the axis orientation flipped
|
||||||
|
lastCmdSign*: float
|
||||||
|
lastLineForward: float
|
||||||
|
lastBotX, lastBotY: float
|
||||||
|
lastTileCol, lastTileRow: int
|
||||||
|
|
||||||
|
proc initStrafe*(): StrafeModule =
|
||||||
|
StrafeModule(debugGraphics: false)
|
||||||
|
|
||||||
|
proc removeBulletNear*(m: var StrafeModule, x, y: float) =
|
||||||
|
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
|
||||||
|
var bestIdx = -1
|
||||||
|
var bestD2 = GridSize * GridSize
|
||||||
|
for i, b in m.bullets:
|
||||||
|
let d2 = (b.x - x)*(b.x - x) + (b.y - y)*(b.y - y)
|
||||||
|
if d2 < bestD2:
|
||||||
|
bestD2 = d2
|
||||||
|
bestIdx = i
|
||||||
|
if bestIdx >= 0:
|
||||||
|
m.bullets.del(bestIdx)
|
||||||
|
|
||||||
|
proc prevEnergyGet(m: StrafeModule, id: int): float =
|
||||||
|
for e in m.prevEnergy:
|
||||||
|
if e.id == id: return e.energy
|
||||||
|
100.0
|
||||||
|
|
||||||
|
proc prevEnergySet(m: var StrafeModule, id: int, energy: float) =
|
||||||
|
for i in 0..<m.prevEnergy.len:
|
||||||
|
if m.prevEnergy[i].id == id:
|
||||||
|
m.prevEnergy[i].energy = energy
|
||||||
|
return
|
||||||
|
m.prevEnergy.add((id: id, energy: energy))
|
||||||
|
|
||||||
|
proc clearGraphics*(m: var StrafeModule) =
|
||||||
|
## No-op: the SVG buffer is a module-level global cleared by the framework
|
||||||
|
## after every go(). Exists so callers can signal "STRAFE is inactive".
|
||||||
|
discard
|
||||||
|
|
||||||
|
proc resetRound*(m: var StrafeModule) =
|
||||||
|
m.bullets = @[]
|
||||||
|
m.prevEnergy = @[]
|
||||||
|
m.targetValid = false
|
||||||
|
m.targetLava = 0.0
|
||||||
|
m.dwell = 0
|
||||||
|
m.dir = 1.0
|
||||||
|
m.bandOffset = 0.0
|
||||||
|
m.lineDir = 0.0
|
||||||
|
m.callCount = 0
|
||||||
|
m.picks = 0
|
||||||
|
m.lastPickCall = 0
|
||||||
|
m.lastCandCount = 0
|
||||||
|
m.lastSafeCount = 0
|
||||||
|
m.fallbackPicks = 0
|
||||||
|
m.reversals = 0
|
||||||
|
m.lineDirFlips = 0
|
||||||
|
m.lastCmdSign = 0.0
|
||||||
|
m.lastLineForward = 0.0
|
||||||
|
m.lastBotX = 0.0
|
||||||
|
m.lastBotY = 0.0
|
||||||
|
m.lastTileCol = 0
|
||||||
|
m.lastTileRow = 0
|
||||||
|
|
||||||
|
proc initGrid(m: var StrafeModule, arenaWidth, arenaHeight: float) =
|
||||||
|
m.cols = int(arenaWidth / GridSize)
|
||||||
|
m.rows = int(arenaHeight / GridSize)
|
||||||
|
m.marginX = (arenaWidth - m.cols.float * GridSize) / 2.0
|
||||||
|
m.marginY = (arenaHeight - m.rows.float * GridSize) / 2.0
|
||||||
|
m.arenaWidth = arenaWidth
|
||||||
|
m.arenaHeight = arenaHeight
|
||||||
|
m.lava = newSeq[float](m.cols * m.rows)
|
||||||
|
|
||||||
|
proc lavaAt(m: StrafeModule, col, row: int): float {.inline.} =
|
||||||
|
m.lava[row * m.cols + col]
|
||||||
|
|
||||||
|
proc tileAt(m: StrafeModule, wx, wy: float): tuple[col, row: int] =
|
||||||
|
(col: clamp(int((wx - m.marginX) / GridSize), 0, m.cols - 1),
|
||||||
|
row: clamp(int((wy - m.marginY) / GridSize), 0, m.rows - 1))
|
||||||
|
|
||||||
|
proc bulletRadii(power: float): tuple[core, aura: float] =
|
||||||
|
let t = (power - 0.1) / 2.9
|
||||||
|
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
|
||||||
|
let auraExt = BulletAuraExtMax - t * (BulletAuraExtMax - BulletAuraExtMin)
|
||||||
|
(core, core + auraExt)
|
||||||
|
|
||||||
|
# ── bullet tracking (copied from the shipped TFIL mover) ─────────────────────
|
||||||
|
|
||||||
|
proc detectFires(m: var StrafeModule, ws: WorldState) =
|
||||||
|
for ei in ws.enemies:
|
||||||
|
let prev = m.prevEnergyGet(ei.id)
|
||||||
|
let drop = prev - ei.energy
|
||||||
|
m.prevEnergySet(ei.id, ei.energy)
|
||||||
|
if drop >= 0.09 and drop <= 3.01:
|
||||||
|
let speed = 20.0 - 3.0 * drop
|
||||||
|
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
|
||||||
|
let travelTime = dist / speed
|
||||||
|
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * DegToRad) * travelTime
|
||||||
|
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
|
||||||
|
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||||||
|
if m.bullets.len >= MaxTrackedBullets:
|
||||||
|
m.bullets.del(0)
|
||||||
|
m.bullets.add TrackedBullet(
|
||||||
|
originX: ei.x, originY: ei.y,
|
||||||
|
x: ei.x, y: ei.y,
|
||||||
|
velX: speed * cos(heading),
|
||||||
|
velY: speed * sin(heading),
|
||||||
|
power: drop, alive: true, age: 0)
|
||||||
|
|
||||||
|
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
|
||||||
|
var i = 0
|
||||||
|
while i < m.bullets.len:
|
||||||
|
var b = m.bullets[i]
|
||||||
|
b.x += b.velX
|
||||||
|
b.y += b.velY
|
||||||
|
b.age += 1
|
||||||
|
let dx = selfX - b.x
|
||||||
|
let dy = selfY - b.y
|
||||||
|
let dot = b.velX * dx + b.velY * dy
|
||||||
|
let outOfBounds = b.x < 0.0 or b.x > m.arenaWidth or b.y < 0.0 or b.y > m.arenaHeight
|
||||||
|
if dot < 0.0 or outOfBounds or b.age > 200:
|
||||||
|
b.alive = false
|
||||||
|
m.bullets[i] = b
|
||||||
|
if b.alive: inc i
|
||||||
|
else: m.bullets.del(i)
|
||||||
|
|
||||||
|
# ── heat field (SHIPPED TFIL field; time model + pillar via the import) ──────
|
||||||
|
|
||||||
|
proc buildHeat(m: var StrafeModule, ws: WorldState) =
|
||||||
|
for i in 0..<m.lava.len: m.lava[i] = 0.0
|
||||||
|
|
||||||
|
# Bullet cores / auras (j105 time model: heat is a function of time-to-arrive).
|
||||||
|
for b in m.bullets:
|
||||||
|
let bx = b.x
|
||||||
|
let by = b.y
|
||||||
|
let (coreR, auraR) = bulletRadii(b.power)
|
||||||
|
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||||||
|
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
|
||||||
|
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
|
||||||
|
let bMag = bulletMagScale(b.power)
|
||||||
|
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
|
||||||
|
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
|
||||||
|
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
|
||||||
|
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
|
||||||
|
for row in rowMin..rowMax:
|
||||||
|
for col in colMin..colMax:
|
||||||
|
let x0 = m.marginX + col.float * GridSize
|
||||||
|
let y0 = m.marginY + row.float * GridSize
|
||||||
|
let nearX = clamp(bx, x0, x0 + GridSize)
|
||||||
|
let nearY = clamp(by, y0, y0 + GridSize)
|
||||||
|
let dx = nearX - bx
|
||||||
|
let dy = nearY - by
|
||||||
|
let d2 = dx*dx + dy*dy
|
||||||
|
if d2 <= coreR * coreR:
|
||||||
|
let along = dx * bUx + dy * bUy
|
||||||
|
m.lava[row * m.cols + col] += BulletCore * bMag * heatDecay(along / bSpeed)
|
||||||
|
elif d2 <= auraR * auraR:
|
||||||
|
let along = dx * bUx + dy * bUy
|
||||||
|
m.lava[row * m.cols + col] += BulletAura * bMag * heatDecay(along / bSpeed)
|
||||||
|
|
||||||
|
# Corridors (rotated rectangle from the bullet to the wall, auraR wide).
|
||||||
|
for b in m.bullets:
|
||||||
|
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||||||
|
if speed < 0.001: continue
|
||||||
|
let dx = b.velX / speed
|
||||||
|
let dy = b.velY / speed
|
||||||
|
let px = -dy
|
||||||
|
let py = dx
|
||||||
|
var tMin = Inf
|
||||||
|
if dx > 0.0: tMin = min(tMin, (m.arenaWidth - b.x) / dx)
|
||||||
|
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
|
||||||
|
if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy)
|
||||||
|
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
|
||||||
|
if tMin == 0.0: continue
|
||||||
|
let (_, auraR) = bulletRadii(b.power)
|
||||||
|
let bMag = bulletMagScale(b.power)
|
||||||
|
let wx = b.x + dx * tMin
|
||||||
|
let wy = b.y + dy * tMin
|
||||||
|
let c0x = b.x + px * auraR; let c0y = b.y + py * auraR
|
||||||
|
let c1x = b.x - px * auraR; let c1y = b.y - py * auraR
|
||||||
|
let c2x = wx - px * auraR; let c2y = wy - py * auraR
|
||||||
|
let c3x = wx + px * auraR; let c3y = wy + py * auraR
|
||||||
|
let xMin = min(min(c0x, c1x), min(c2x, c3x))
|
||||||
|
let xMax = max(max(c0x, c1x), max(c2x, c3x))
|
||||||
|
let yMin = min(min(c0y, c1y), min(c2y, c3y))
|
||||||
|
let yMax = max(max(c0y, c1y), max(c2y, c3y))
|
||||||
|
let colMin = max(0, int(floor((xMin - m.marginX) / GridSize)))
|
||||||
|
let colMax = min(m.cols-1, int(floor((xMax - m.marginX) / GridSize)))
|
||||||
|
let rowMin = max(0, int(floor((yMin - m.marginY) / GridSize)))
|
||||||
|
let rowMax = min(m.rows-1, int(floor((yMax - m.marginY) / GridSize)))
|
||||||
|
for row in rowMin..rowMax:
|
||||||
|
for col in colMin..colMax:
|
||||||
|
let cx = m.marginX + (col.float + 0.5) * GridSize
|
||||||
|
let cy = m.marginY + (row.float + 0.5) * GridSize
|
||||||
|
let relX = cx - b.x
|
||||||
|
let relY = cy - b.y
|
||||||
|
let along = relX * dx + relY * dy
|
||||||
|
let perp = relX * px + relY * py
|
||||||
|
if along >= 0.0 and along <= tMin and perp >= -auraR and perp <= auraR:
|
||||||
|
m.lava[row * m.cols + col] += StrafeCorridorHeat * bMag * heatDecay(along / speed)
|
||||||
|
|
||||||
|
# Enemy auras
|
||||||
|
for ei in ws.enemies:
|
||||||
|
let ex = ei.x
|
||||||
|
let ey = ei.y
|
||||||
|
let colMin = max(0, int(floor((ex - EnemyAuraRadius - m.marginX) / GridSize)))
|
||||||
|
let colMax = min(m.cols-1, int(floor((ex + EnemyAuraRadius - m.marginX) / GridSize)))
|
||||||
|
let rowMin = max(0, int(floor((ey - EnemyAuraRadius - m.marginY) / GridSize)))
|
||||||
|
let rowMax = min(m.rows-1, int(floor((ey + EnemyAuraRadius - m.marginY) / GridSize)))
|
||||||
|
for row in rowMin..rowMax:
|
||||||
|
for col in colMin..colMax:
|
||||||
|
let x0 = m.marginX + col.float * GridSize
|
||||||
|
let y0 = m.marginY + row.float * GridSize
|
||||||
|
let nearX = clamp(ex, x0, x0 + GridSize)
|
||||||
|
let nearY = clamp(ey, y0, y0 + GridSize)
|
||||||
|
let dx = nearX - ex
|
||||||
|
let dy = nearY - ey
|
||||||
|
let d2 = dx*dx + dy*dy
|
||||||
|
if d2 <= EnemyCoreRadius * EnemyCoreRadius:
|
||||||
|
m.lava[row * m.cols + col] += EnemyCore
|
||||||
|
elif d2 <= EnemyAuraRadius * EnemyAuraRadius:
|
||||||
|
m.lava[row * m.cols + col] += EnemyAura
|
||||||
|
|
||||||
|
# Wall radiance
|
||||||
|
for row in 0..<m.rows:
|
||||||
|
for col in 0..<m.cols:
|
||||||
|
let heat = max(0.0, StrafeWallHotness - col.float * StrafeWallRadiance) +
|
||||||
|
max(0.0, StrafeWallHotness - (m.cols-1-col).float * StrafeWallRadiance) +
|
||||||
|
max(0.0, StrafeWallHotness - row.float * StrafeWallRadiance) +
|
||||||
|
max(0.0, StrafeWallHotness - (m.rows-1-row).float * StrafeWallRadiance)
|
||||||
|
m.lava[row * m.cols + col] += heat
|
||||||
|
|
||||||
|
# Pillar radiance (imported globals; 0/0 = the shipped pillar-free default).
|
||||||
|
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
||||||
|
let pc1 = m.cols div 2
|
||||||
|
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
|
||||||
|
let pr1 = m.rows div 2
|
||||||
|
for row in 0..<m.rows:
|
||||||
|
for col in 0..<m.cols:
|
||||||
|
var minDist = int.high
|
||||||
|
for pcol in pc0..pc1:
|
||||||
|
for prow in pr0..pr1:
|
||||||
|
let d = max(abs(col - pcol), abs(row - prow))
|
||||||
|
if d < minDist: minDist = d
|
||||||
|
m.lava[row * m.cols + col] += max(0.0, PillarHotness - minDist.float * PillarRadiance)
|
||||||
|
|
||||||
|
proc pathMaxHeat(m: StrafeModule, x0, y0, x1, y1: float): float =
|
||||||
|
let dx = x1 - x0
|
||||||
|
let dy = y1 - y0
|
||||||
|
let dist = sqrt(dx*dx + dy*dy)
|
||||||
|
if dist < 0.1: return 0.0
|
||||||
|
let steps = max(1, int(dist / PathSampleStep))
|
||||||
|
for si in 0..steps:
|
||||||
|
let f = si.float / steps.float
|
||||||
|
let (c, r) = m.tileAt(x0 + dx * f, y0 + dy * f)
|
||||||
|
result = max(result, m.lavaAt(c, r))
|
||||||
|
|
||||||
|
# ── the threat axis ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc threatBearing(m: StrafeModule, ws: WorldState): float =
|
||||||
|
## Incoming bullet direction when a bullet is in flight (nearest one), else
|
||||||
|
## the enemy bearing.
|
||||||
|
var bestD = Inf
|
||||||
|
var found = false
|
||||||
|
for b in m.bullets:
|
||||||
|
let d = hypot(b.x - ws.selfX, b.y - ws.selfY)
|
||||||
|
if d < bestD:
|
||||||
|
bestD = d
|
||||||
|
result = arctan2(b.velY, b.velX) * 180.0 / PI
|
||||||
|
found = true
|
||||||
|
if found: return result
|
||||||
|
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
|
||||||
|
return arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * 180.0 / PI
|
||||||
|
for ei in ws.enemies:
|
||||||
|
return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# ── target picking ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
Cand = object
|
||||||
|
col, row: int
|
||||||
|
x, y: float
|
||||||
|
pathHeat: float
|
||||||
|
along: float ## signed offset along the line (+ = forward of lineForward)
|
||||||
|
|
||||||
|
proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float) =
|
||||||
|
let ux = cos(lineForward * DegToRad)
|
||||||
|
let uy = sin(lineForward * DegToRad)
|
||||||
|
let px = -uy
|
||||||
|
let py = ux
|
||||||
|
let kmax = max(1, int(StrafeReach / GridSize))
|
||||||
|
let spread = max(0, StrafeSpread)
|
||||||
|
let (bc, br) = m.tileAt(ws.selfX, ws.selfY)
|
||||||
|
|
||||||
|
var cands: seq[Cand]
|
||||||
|
for k in 1..kmax:
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
let along = s * k.float * GridSize
|
||||||
|
for j in -spread..spread:
|
||||||
|
let wx = ws.selfX + ux * along + px * (j.float * GridSize)
|
||||||
|
let wy = ws.selfY + uy * along + py * (j.float * GridSize)
|
||||||
|
if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue
|
||||||
|
let (c, r) = m.tileAt(wx, wy)
|
||||||
|
if c == bc and r == br: continue
|
||||||
|
var dup = false
|
||||||
|
for e in cands:
|
||||||
|
if e.col == c and e.row == r: dup = true; break
|
||||||
|
if dup: continue
|
||||||
|
let cx = m.marginX + (c.float + 0.5) * GridSize
|
||||||
|
let cy = m.marginY + (r.float + 0.5) * GridSize
|
||||||
|
cands.add Cand(col: c, row: r, x: cx, y: cy,
|
||||||
|
pathHeat: m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy),
|
||||||
|
along: along)
|
||||||
|
|
||||||
|
var safe: seq[Cand]
|
||||||
|
for c in cands:
|
||||||
|
if c.pathHeat <= PathDangerThreshold: safe.add c
|
||||||
|
m.lastCandCount = cands.len
|
||||||
|
m.lastSafeCount = safe.len
|
||||||
|
|
||||||
|
var pool: seq[Cand]
|
||||||
|
if safe.len > 0:
|
||||||
|
pool = safe
|
||||||
|
elif cands.len > 0:
|
||||||
|
# Fallback (never freeze): the two coolest tiles on the line, over
|
||||||
|
# threshold but still the least dangerous direction to move.
|
||||||
|
var sorted = cands
|
||||||
|
for i in 1..<sorted.len:
|
||||||
|
let key = sorted[i]
|
||||||
|
var j = i - 1
|
||||||
|
while j >= 0 and sorted[j].pathHeat > key.pathHeat:
|
||||||
|
sorted[j + 1] = sorted[j]
|
||||||
|
dec j
|
||||||
|
sorted[j + 1] = key
|
||||||
|
let take = min(2, sorted.len)
|
||||||
|
for i in 0..<take: pool.add sorted[i]
|
||||||
|
inc m.fallbackPicks
|
||||||
|
|
||||||
|
if pool.len == 0:
|
||||||
|
m.targetValid = false
|
||||||
|
return
|
||||||
|
|
||||||
|
let chosen = rand(pool.high)
|
||||||
|
m.targetX = pool[chosen].x
|
||||||
|
m.targetY = pool[chosen].y
|
||||||
|
m.targetLava = m.lavaAt(pool[chosen].col, pool[chosen].row)
|
||||||
|
m.targetValid = true
|
||||||
|
|
||||||
|
# sign is frozen for the whole dwell: reversal timing == dwell timing.
|
||||||
|
let hx = cos(ws.selfHeading * DegToRad)
|
||||||
|
let hy = sin(ws.selfHeading * DegToRad)
|
||||||
|
let alongDot = (m.targetX - ws.selfX) * hx + (m.targetY - ws.selfY) * hy
|
||||||
|
m.dir = if alongDot >= 0.0: 1.0 else: -1.0
|
||||||
|
if m.lastCmdSign == 0.0: m.lastCmdSign = m.dir
|
||||||
|
|
||||||
|
# Randomised dwell + randomisation inside the band (point 2 / point 6).
|
||||||
|
m.dwell = rand(StrafeDwellMin..StrafeDwellMax)
|
||||||
|
m.bandOffset = rand(2.0 * StrafeBand) - StrafeBand
|
||||||
|
m.lastPickCall = m.callCount
|
||||||
|
inc m.picks
|
||||||
|
|
||||||
|
if StrafeLog:
|
||||||
|
let reason = if safe.len == 0: "fallback" else: "pick"
|
||||||
|
echo fmt"[strafe] {reason} n={cands.len} safe={safe.len} " &
|
||||||
|
fmt"along={pool[chosen].along.int} dir={m.dir.int} " &
|
||||||
|
fmt"dwell={m.dwell} line={lineForward.int} band={m.bandOffset:.0f}"
|
||||||
|
if safe.len == 0:
|
||||||
|
echo fmt"[strafe] WARNING: no SAFE tile on the line " &
|
||||||
|
fmt"(cands={cands.len}, least-hot={pool[chosen].pathHeat:.1f})"
|
||||||
|
|
||||||
|
# ── main entry point ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc computeMove*(m: var StrafeModule, ws: WorldState): MoveCommand =
|
||||||
|
if m.cols == 0:
|
||||||
|
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||||
|
|
||||||
|
# Soft reset on a position jump (a ram teleport moved us).
|
||||||
|
let jumpDist = sqrt((ws.selfX - m.lastBotX)^2 + (ws.selfY - m.lastBotY)^2)
|
||||||
|
let jumped = (m.callCount > 0) and (jumpDist > 12.0)
|
||||||
|
if jumped:
|
||||||
|
m.dwell = 0
|
||||||
|
m.targetValid = false
|
||||||
|
m.bullets = @[]
|
||||||
|
m.prevEnergy = @[]
|
||||||
|
for ei in ws.enemies:
|
||||||
|
m.prevEnergySet(ei.id, ei.energy)
|
||||||
|
|
||||||
|
m.advanceBullets(ws.selfX, ws.selfY)
|
||||||
|
m.detectFires(ws)
|
||||||
|
m.buildHeat(ws)
|
||||||
|
|
||||||
|
# ── threat axis -> perpendicular line ──
|
||||||
|
let threat = m.threatBearing(ws)
|
||||||
|
let lineAngle = threat + 90.0
|
||||||
|
m.lineDir = lineAngle
|
||||||
|
|
||||||
|
# Forward orientation = the half of the (undirected) line nearest our heading.
|
||||||
|
var lineForward = lineAngle
|
||||||
|
let hRel = wrap180(ws.selfHeading - lineAngle)
|
||||||
|
if hRel > 90.0 or hRel < -90.0:
|
||||||
|
lineForward = lineAngle + 180.0
|
||||||
|
if m.picks > 0:
|
||||||
|
let fl = abs(wrap180(lineForward - m.lastLineForward))
|
||||||
|
if fl > 90.0: inc m.lineDirFlips
|
||||||
|
m.lastLineForward = lineForward
|
||||||
|
|
||||||
|
# ── target (re)pick ──
|
||||||
|
var serious = false
|
||||||
|
if m.targetValid:
|
||||||
|
let (tc, tr) = m.tileAt(m.targetX, m.targetY)
|
||||||
|
if m.lavaAt(tc, tr) > m.targetLava + DangerReplanThreshold:
|
||||||
|
serious = true
|
||||||
|
if hypot(m.targetX - ws.selfX, m.targetY - ws.selfY) < GridSize * 0.75:
|
||||||
|
m.dwell = 0
|
||||||
|
if (not m.targetValid) or m.dwell <= 0 or serious:
|
||||||
|
m.pickTarget(ws, lineForward)
|
||||||
|
else:
|
||||||
|
dec m.dwell
|
||||||
|
|
||||||
|
# ── heading band: turn ONLY to stay perpendicular, never to the target ──
|
||||||
|
var turnRate = 0.0
|
||||||
|
let mtr = 10.0 - 0.75 * abs(ws.selfSpeed)
|
||||||
|
let dev = wrap180(ws.selfHeading - lineForward)
|
||||||
|
if abs(dev) > StrafeBand:
|
||||||
|
let targetHeading = lineForward + m.bandOffset
|
||||||
|
turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr)
|
||||||
|
|
||||||
|
# ── move by sign only ──
|
||||||
|
# Never freeze: if there is no valid target (the rare all-candidates-outside
|
||||||
|
# case) keep driving on the last sign. The normal path picks a target every
|
||||||
|
# dwell, and the fallback pool is non-empty whenever any candidate tile exists.
|
||||||
|
if m.targetValid and m.dir != m.lastCmdSign:
|
||||||
|
inc m.reversals
|
||||||
|
m.lastCmdSign = m.dir
|
||||||
|
result = (speed: MaxSpeed * (if m.dir != 0.0: m.dir else: 1.0), turnRate: turnRate)
|
||||||
|
|
||||||
|
# ── GUI overlay (the user watches this) ──
|
||||||
|
if m.debugGraphics:
|
||||||
|
let ux = cos(lineForward * DegToRad)
|
||||||
|
let uy = sin(lineForward * DegToRad)
|
||||||
|
|
||||||
|
# Strafe line across the arena (cyan).
|
||||||
|
setStrokeColor(fromHex("#00FFFF"))
|
||||||
|
setStrokeWidth(1.0)
|
||||||
|
drawLine(ws.selfX - ux * 600.0, ws.selfY - uy * 600.0,
|
||||||
|
ws.selfX + ux * 600.0, ws.selfY + uy * 600.0)
|
||||||
|
|
||||||
|
# Threat axis (enemy/bullet bearing) as a faint grey line through us.
|
||||||
|
let tx = cos(threat * DegToRad)
|
||||||
|
let ty = sin(threat * DegToRad)
|
||||||
|
setStrokeColor(fromHex("#888888"))
|
||||||
|
drawLine(ws.selfX - tx * 600.0, ws.selfY - ty * 600.0,
|
||||||
|
ws.selfX + tx * 600.0, ws.selfY + ty * 600.0)
|
||||||
|
|
||||||
|
# Candidate line reach up to kmax tiles: safe tiles bright, unsafe dim.
|
||||||
|
let kmax = max(1, int(StrafeReach / GridSize))
|
||||||
|
let spread = max(0, StrafeSpread)
|
||||||
|
let px = -uy
|
||||||
|
let py = ux
|
||||||
|
for k in 1..kmax:
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
let along = s * k.float * GridSize
|
||||||
|
for j in -spread..spread:
|
||||||
|
let wx = ws.selfX + ux * along + px * (j.float * GridSize)
|
||||||
|
let wy = ws.selfY + uy * along + py * (j.float * GridSize)
|
||||||
|
if wx < 0.0 or wx >= m.arenaWidth or wy < 0.0 or wy >= m.arenaHeight: continue
|
||||||
|
let (c, r) = m.tileAt(wx, wy)
|
||||||
|
let x0 = m.marginX + c.float * GridSize
|
||||||
|
let y0 = m.marginY + r.float * GridSize
|
||||||
|
if m.lavaAt(c, r) <= PathDangerThreshold:
|
||||||
|
setStrokeColor(fromHex("#00FF00"))
|
||||||
|
setStrokeWidth(1.0)
|
||||||
|
else:
|
||||||
|
setStrokeColor(fromHex("#804000"))
|
||||||
|
setStrokeWidth(1.0)
|
||||||
|
drawRectangle(x0, y0, GridSize, GridSize)
|
||||||
|
|
||||||
|
# Chosen target tile (magenta fill) + the sign-coloured movement ray.
|
||||||
|
if m.targetValid:
|
||||||
|
let (cc, cr) = m.tileAt(m.targetX, m.targetY)
|
||||||
|
let gx0 = m.marginX + cc.float * GridSize
|
||||||
|
let gy0 = m.marginY + cr.float * GridSize
|
||||||
|
setStrokeColor(fromHex("#FF00FF"))
|
||||||
|
setStrokeWidth(2.5)
|
||||||
|
drawRectangle(gx0, gy0, GridSize, GridSize)
|
||||||
|
setFillColor(fromHex("#FF00FF"))
|
||||||
|
fillCircle(m.targetX, m.targetY, 5.0)
|
||||||
|
# movement ray: green forward, red backward (the sign flip is the point)
|
||||||
|
setStrokeColor(if m.dir >= 0.0: fromHex("#00FF00") else: fromHex("#FF2222"))
|
||||||
|
setStrokeWidth(2.0)
|
||||||
|
drawLine(ws.selfX, ws.selfY,
|
||||||
|
ws.selfX + ux * m.dir * StrafeReach, ws.selfY + uy * m.dir * StrafeReach)
|
||||||
|
|
||||||
|
# Heading band: the two ±band boundary rays (yellow) around the line.
|
||||||
|
setStrokeColor(fromHex("#FFFF00"))
|
||||||
|
setStrokeWidth(1.0)
|
||||||
|
let bl = (lineForward - StrafeBand) * DegToRad
|
||||||
|
let br2 = (lineForward + StrafeBand) * DegToRad
|
||||||
|
drawLine(ws.selfX, ws.selfY,
|
||||||
|
ws.selfX + cos(bl) * 70.0, ws.selfY + sin(bl) * 70.0)
|
||||||
|
drawLine(ws.selfX, ws.selfY,
|
||||||
|
ws.selfX + cos(br2) * 70.0, ws.selfY + sin(br2) * 70.0)
|
||||||
|
# Current heading ray (blue).
|
||||||
|
setStrokeColor(fromHex("#3399FF"))
|
||||||
|
setStrokeWidth(2.0)
|
||||||
|
drawLine(ws.selfX, ws.selfY,
|
||||||
|
ws.selfX + cos(ws.selfHeading * DegToRad) * 60.0,
|
||||||
|
ws.selfY + sin(ws.selfHeading * DegToRad) * 60.0)
|
||||||
|
|
||||||
|
# snapshot for the next call
|
||||||
|
m.lastBotX = ws.selfX
|
||||||
|
m.lastBotY = ws.selfY
|
||||||
|
m.lastTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
||||||
|
m.lastTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||||||
|
m.callCount += 1
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
## STRAFE gates — the two CHEAP GATES the spec demands, run OFFLINE on the
|
||||||
|
## recorded DrussGT fixture (no Java, no server, no battle).
|
||||||
|
##
|
||||||
|
## nim c -r --nimcache:/tmp/nc_j108 --path:common_libs \
|
||||||
|
## common_libs/tests/measure_strafe_gates.nim
|
||||||
|
##
|
||||||
|
## GATE A TILE AVAILABILITY: on every STRAFE pick, how many of the tiles on the
|
||||||
|
## perpendicular line are SAFE (path max heat <= PathDangerThreshold),
|
||||||
|
## and how often the safe pool is EMPTY (the mover then falls back to
|
||||||
|
## the least-hot tile). If the line is usually blocked the design fails
|
||||||
|
## by construction and this prints it.
|
||||||
|
##
|
||||||
|
## GATE B PREDICTABILITY: the main risk. A constant heading running back and
|
||||||
|
## forth on one line is exactly what a pattern-matching gun exploits.
|
||||||
|
## Both movers are replayed over the SAME fixture with the SAME seed and
|
||||||
|
## compared on:
|
||||||
|
## * reversal-interval entropy (bits) — higher = less periodic
|
||||||
|
## * direction entropy (bits) — higher = less biased
|
||||||
|
## * max |autocorrelation| of the signed direction over lags 20..60
|
||||||
|
## * mean |turnRate| and the fraction of ticks with no turn at all
|
||||||
|
## * mean |speed| around a reversal (the j85 speed-collapse test)
|
||||||
|
##
|
||||||
|
## The fixture was recorded with DrussGT as the SUBJECT and ModularBot as the
|
||||||
|
## adversary (see the file's meta line), so `sx/sy/sh` are OUR recorded states
|
||||||
|
## and `ex/ey/ee` are DrussGT's. Replaying it drives each mover open-loop; that
|
||||||
|
## is the standard repo practice for movement gates.
|
||||||
|
|
||||||
|
import std/[os, json, math, random, strformat, strutils, algorithm, tables, sequtils]
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
import movements/the_floor_is_lava
|
||||||
|
import movements/strafe
|
||||||
|
|
||||||
|
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||||
|
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||||
|
const Seed = 20250923
|
||||||
|
const ArenaW = 800.0
|
||||||
|
const ArenaH = 600.0
|
||||||
|
|
||||||
|
proc loadStates(): seq[WorldState] =
|
||||||
|
let path = repoRoot / "tools" / "fixtures" / fixtureRel
|
||||||
|
for rawLine in lines(path):
|
||||||
|
let line = rawLine.strip()
|
||||||
|
if line.len == 0: continue
|
||||||
|
let n = parseJson(line)
|
||||||
|
if n.hasKey("meta") or n.hasKey("end"): continue
|
||||||
|
let ex = n["ex"].getFloat()
|
||||||
|
let ey = n["ey"].getFloat()
|
||||||
|
result.add WorldState(
|
||||||
|
enemyX: ex, enemyY: ey,
|
||||||
|
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
|
||||||
|
enemyEnergy: n["ee"].getFloat(),
|
||||||
|
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
|
||||||
|
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
|
||||||
|
selfEnergy: n["se"].getFloat(),
|
||||||
|
arenaWidth: ArenaW, arenaHeight: ArenaH,
|
||||||
|
tick: n["tick"].getInt(),
|
||||||
|
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
|
||||||
|
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
|
||||||
|
energy: n["ee"].getFloat())])
|
||||||
|
|
||||||
|
proc loadRoundStarts(): seq[int] =
|
||||||
|
let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" /
|
||||||
|
(fixtureRel & ".rounds.json")
|
||||||
|
if not fileExists(side): return
|
||||||
|
for r in parseFile(side)["rounds"]:
|
||||||
|
result.add r["startTick"].getInt()
|
||||||
|
|
||||||
|
# ── stats helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc entropy(vals: openArray[int]): float =
|
||||||
|
## Empirical Shannon entropy (bits) over the distinct values in `vals`.
|
||||||
|
if vals.len == 0: return 0.0
|
||||||
|
var counts = initCountTable[int]()
|
||||||
|
for v in vals: counts.inc(v)
|
||||||
|
for c in counts.values:
|
||||||
|
let p = c.float / vals.len.float
|
||||||
|
result -= p * log2(p)
|
||||||
|
|
||||||
|
proc meanF(s: openArray[float]): float =
|
||||||
|
if s.len == 0: return 0.0
|
||||||
|
var t = 0.0
|
||||||
|
for v in s: t += v
|
||||||
|
t / s.len.float
|
||||||
|
|
||||||
|
proc autocorrAt(x: seq[float], k: int): float =
|
||||||
|
let n = x.len
|
||||||
|
if n < k + 2: return 0.0
|
||||||
|
let mu = meanF(x)
|
||||||
|
var denom = 0.0
|
||||||
|
for v in x: denom += (v - mu) * (v - mu)
|
||||||
|
if denom <= 0.0: return 0.0
|
||||||
|
var num = 0.0
|
||||||
|
for i in 0..<(n - k):
|
||||||
|
num += (x[i] - mu) * (x[i + k] - mu)
|
||||||
|
num / denom
|
||||||
|
|
||||||
|
proc autocorrPeak(x: seq[float], loLag, hiLag: int): tuple[r: float, lag: int] =
|
||||||
|
## Max |autocorr| over a LONG-lag window, with the lag that produced it.
|
||||||
|
## Long lags beat persistence: a periodic reversal shows a peak here, while a
|
||||||
|
## random telegraph has already decayed to ~0.
|
||||||
|
for k in loLag..hiLag:
|
||||||
|
let r = autocorrAt(x, k)
|
||||||
|
if abs(r) > abs(result.r): result = (r, k)
|
||||||
|
|
||||||
|
proc coeffVar(vals: openArray[int]): float =
|
||||||
|
## Coefficient of variation (sd/mean) of the reversal intervals.
|
||||||
|
if vals.len == 0: return 0.0
|
||||||
|
let mu = meanF(vals.mapIt(it.float))
|
||||||
|
if mu <= 0.0: return 0.0
|
||||||
|
var acc = 0.0
|
||||||
|
for v in vals: acc += (v.float - mu) * (v.float - mu)
|
||||||
|
sqrt(acc / vals.len.float) / mu
|
||||||
|
|
||||||
|
proc reversalIntervals(signs: seq[float]): seq[int] =
|
||||||
|
## Intervals (in ticks) between successive sign flips of the commanded speed.
|
||||||
|
var last = 0.0
|
||||||
|
var lastIdx = -1
|
||||||
|
for i, s in signs:
|
||||||
|
if s == 0.0: continue
|
||||||
|
if last != 0.0 and s != last:
|
||||||
|
if lastIdx >= 0: result.add i - lastIdx
|
||||||
|
lastIdx = i
|
||||||
|
last = s
|
||||||
|
|
||||||
|
proc directionEntropy(signs: seq[float]): float =
|
||||||
|
## Bernoulli entropy of the commanded direction (forward vs backward).
|
||||||
|
var fwd, bwd: int
|
||||||
|
for s in signs:
|
||||||
|
if s > 0.0: inc fwd
|
||||||
|
elif s < 0.0: inc bwd
|
||||||
|
if fwd + bwd == 0: return 0.0
|
||||||
|
entropy(@[fwd, bwd])
|
||||||
|
|
||||||
|
# ── replays ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
Replay = object
|
||||||
|
signs: seq[float] ## commanded speed sign per tick
|
||||||
|
turn: seq[float] ## turnRate per tick
|
||||||
|
absSpeed: seq[float] ## |speed| per tick
|
||||||
|
revIdx: seq[int] ## tick indices of the reversals
|
||||||
|
# STRAFE-only
|
||||||
|
picks: int
|
||||||
|
pickIntervals: seq[int]
|
||||||
|
safeCounts: seq[int]
|
||||||
|
candCounts: seq[int]
|
||||||
|
fallbackPicks: int
|
||||||
|
lineDirFlips: int
|
||||||
|
|
||||||
|
proc replayTfil(states: seq[WorldState], starts: seq[int]): Replay =
|
||||||
|
randomize(Seed)
|
||||||
|
var m = initTFIL()
|
||||||
|
var prevSign = 0.0
|
||||||
|
for i in 0..<states.len:
|
||||||
|
if i == 0 or i in starts: m.resetRound()
|
||||||
|
let cmd = m.computeMove(states[i])
|
||||||
|
let sign = if cmd.speed > 0.0: 1.0 elif cmd.speed < 0.0: -1.0 else: 0.0
|
||||||
|
result.signs.add sign
|
||||||
|
result.turn.add cmd.turnRate
|
||||||
|
result.absSpeed.add abs(cmd.speed)
|
||||||
|
if sign != 0.0 and prevSign != 0.0 and sign != prevSign:
|
||||||
|
result.revIdx.add i
|
||||||
|
if sign != 0.0: prevSign = sign
|
||||||
|
|
||||||
|
proc replayStrafe(states: seq[WorldState], starts: seq[int]): Replay =
|
||||||
|
randomize(Seed)
|
||||||
|
var m = initStrafe()
|
||||||
|
var prevSign = 0.0
|
||||||
|
var prevPicks = 0
|
||||||
|
var prevLastPick = 0
|
||||||
|
var prevFlips = 0
|
||||||
|
for i in 0..<states.len:
|
||||||
|
if i == 0 or i in starts:
|
||||||
|
m.resetRound()
|
||||||
|
prevPicks = 0
|
||||||
|
prevLastPick = i
|
||||||
|
prevFlips = 0
|
||||||
|
let cmd = m.computeMove(states[i])
|
||||||
|
let sign = if cmd.speed > 0.0: 1.0 elif cmd.speed < 0.0: -1.0 else: 0.0
|
||||||
|
result.signs.add sign
|
||||||
|
result.turn.add cmd.turnRate
|
||||||
|
result.absSpeed.add abs(cmd.speed)
|
||||||
|
if sign != 0.0 and prevSign != 0.0 and sign != prevSign:
|
||||||
|
result.revIdx.add i
|
||||||
|
if sign != 0.0: prevSign = sign
|
||||||
|
if m.picks > prevPicks:
|
||||||
|
inc result.picks
|
||||||
|
result.safeCounts.add m.lastSafeCount
|
||||||
|
result.candCounts.add m.lastCandCount
|
||||||
|
if m.lastSafeCount == 0: inc result.fallbackPicks
|
||||||
|
result.pickIntervals.add i - prevLastPick
|
||||||
|
prevLastPick = i
|
||||||
|
prevPicks = m.picks
|
||||||
|
if m.lineDirFlips > prevFlips:
|
||||||
|
result.lineDirFlips += m.lineDirFlips - prevFlips
|
||||||
|
prevFlips = m.lineDirFlips
|
||||||
|
|
||||||
|
proc reversalSpeedProfile(rep: Replay, offsets: seq[int]): seq[float] =
|
||||||
|
result = newSeq[float](offsets.len)
|
||||||
|
var n = newSeq[int](offsets.len)
|
||||||
|
for i in rep.revIdx:
|
||||||
|
for oi, off in offsets:
|
||||||
|
let j = i + off
|
||||||
|
if j >= 0 and j < rep.absSpeed.len:
|
||||||
|
result[oi] += rep.absSpeed[j]
|
||||||
|
inc n[oi]
|
||||||
|
for oi in 0..<offsets.len:
|
||||||
|
if n[oi] > 0: result[oi] /= n[oi].float
|
||||||
|
else: result[oi] = NaN
|
||||||
|
|
||||||
|
proc fmtF(x: float, d = 3): string =
|
||||||
|
if x.classify == fcNan: "-"
|
||||||
|
else: formatFloat(x, ffDecimal, d)
|
||||||
|
|
||||||
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let states = loadStates()
|
||||||
|
let starts = loadRoundStarts()
|
||||||
|
let nTicks = states.len
|
||||||
|
|
||||||
|
let tfil = replayTfil(states, starts)
|
||||||
|
let sf = replayStrafe(states, starts)
|
||||||
|
|
||||||
|
# Field variant: the ring mover's retune, where no SINGLE soft source can poison
|
||||||
|
# a path (corridor below the threshold, wall radiance == the threshold). This is
|
||||||
|
# the same field shape the user's ring experiment used; we only test whether it
|
||||||
|
# unblocks the strafe line.
|
||||||
|
StrafeCorridorHeat = 5.0
|
||||||
|
StrafeWallHotness = 10.0
|
||||||
|
StrafeWallRadiance = 5.0
|
||||||
|
let sfRetune = replayStrafe(states, starts)
|
||||||
|
loadStrafeHeatEnv() # restore the shipped field for any later use
|
||||||
|
|
||||||
|
echo "STRAFE gates — offline fixture ", fixtureRel, " (", nTicks, " ticks, ",
|
||||||
|
starts.len, " rounds), seed=", Seed
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── GATE A ───────────────────────────────────────────────────────────────────
|
||||||
|
echo "=== GATE A — TILE AVAILABILITY (every STRAFE pick) ==="
|
||||||
|
proc gateA(tag: string, rep: Replay) =
|
||||||
|
let picks = rep.picks
|
||||||
|
echo "--- field: ", tag, " ---"
|
||||||
|
echo " picks total : ", picks
|
||||||
|
if picks == 0: return
|
||||||
|
echo " picks with ZERO safe tile: ", rep.fallbackPicks, " (",
|
||||||
|
fmtF(100.0 * rep.fallbackPicks.float / picks.float, 1), "%)"
|
||||||
|
echo " mean candidates on line : ",
|
||||||
|
fmtF(meanF(rep.candCounts.mapIt(it.float)), 2)
|
||||||
|
echo " mean SAFE candidates : ",
|
||||||
|
fmtF(meanF(rep.safeCounts.mapIt(it.float)), 2)
|
||||||
|
var anySafe = 0
|
||||||
|
for s in rep.safeCounts:
|
||||||
|
if s > 0: inc anySafe
|
||||||
|
echo " line has a SAFE tile at : ",
|
||||||
|
fmtF(100.0 * anySafe.float / picks.float, 1), "% of picks"
|
||||||
|
var hist = initCountTable[int]()
|
||||||
|
for s in rep.safeCounts: hist.inc(min(s, 12))
|
||||||
|
var line = ""
|
||||||
|
for k in 0..12:
|
||||||
|
line.add &"{k}:{hist.getOrDefault(k,0)} "
|
||||||
|
echo " safe-count dist (0..12+) : ", line
|
||||||
|
|
||||||
|
gateA("shipped TFIL heat (corridor 20, wall 30/10)", sf)
|
||||||
|
gateA("ring retune (corridor 5, wall 10/5)", sfRetune)
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── GATE B ───────────────────────────────────────────────────────────────────
|
||||||
|
echo "=== GATE B — PREDICTABILITY (STRAFE vs shipped TFIL) ==="
|
||||||
|
let Offsets = @[-6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||||
|
|
||||||
|
proc reportMover(name: string, rep: Replay, strafeExtra: bool) =
|
||||||
|
let revInt = reversalIntervals(rep.signs)
|
||||||
|
let revEnt = entropy(revInt)
|
||||||
|
let dirEnt = directionEntropy(rep.signs)
|
||||||
|
var nzTurn = 0
|
||||||
|
for t in rep.turn:
|
||||||
|
if abs(t) < 0.5: inc nzTurn
|
||||||
|
let prof = reversalSpeedProfile(rep, Offsets)
|
||||||
|
echo "--- ", name, " ---"
|
||||||
|
echo " reversals : ", rep.revIdx.len
|
||||||
|
echo " mean reversal interval : ", fmtF(meanF(revInt.mapIt(it.float)), 2), " ticks"
|
||||||
|
echo " reversal-interval CV : ", fmtF(coeffVar(revInt), 3)
|
||||||
|
echo " REVERSAL-INTERVAL ENTROPY: ", fmtF(revEnt, 3), " bits"
|
||||||
|
echo " DIRECTION ENTROPY : ", fmtF(dirEnt, 3), " bits"
|
||||||
|
echo " autocorr lag-1 : ", fmtF(autocorrAt(rep.signs,1), 3), " (persistence)"
|
||||||
|
let pk = autocorrPeak(rep.signs, 20, 60)
|
||||||
|
echo " max |autocorr| (20..60) : ", fmtF(pk.r, 3), " at lag ", pk.lag, " (periodicity)"
|
||||||
|
echo " mean |turnRate| : ", fmtF(meanF(rep.turn.mapIt(abs(it))), 2), " deg/tick"
|
||||||
|
echo " ticks with no turn (<0.5): ", fmtF(100.0 * nzTurn.float / rep.turn.len.float, 1), "%"
|
||||||
|
echo " mean |speed| : ", fmtF(meanF(rep.absSpeed), 2), " px/tick"
|
||||||
|
if strafeExtra:
|
||||||
|
echo " pick interval entropy : ", fmtF(entropy(rep.pickIntervals), 3), " bits"
|
||||||
|
echo " mean pick interval : ", fmtF(meanF(rep.pickIntervals.mapIt(it.float)), 2), " ticks"
|
||||||
|
echo " line-axis orientation flips: ", rep.lineDirFlips
|
||||||
|
var cells = ""
|
||||||
|
for i in 0..<Offsets.len:
|
||||||
|
if i > 0: cells.add " "
|
||||||
|
cells.add &"{Offsets[i]:+d}:{fmtF(prof[i],1)}"
|
||||||
|
echo " |speed| around reversal : ", cells
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
reportMover("STRAFE", sf, true)
|
||||||
|
reportMover("TFIL (shipped default)", tfil, false)
|
||||||
|
|
||||||
|
# ── honest read ──────────────────────────────────────────────────────────────
|
||||||
|
echo "=== HONEST READ ==="
|
||||||
|
let revIntS = reversalIntervals(sf.signs)
|
||||||
|
let revIntT = reversalIntervals(tfil.signs)
|
||||||
|
let revEntS = entropy(revIntS)
|
||||||
|
let revEntT = entropy(revIntT)
|
||||||
|
let dirEntS = directionEntropy(sf.signs)
|
||||||
|
let dirEntT = directionEntropy(tfil.signs)
|
||||||
|
echo "reversal-interval entropy: STRAFE ", fmtF(revEntS,2), " vs TFIL ", fmtF(revEntT,2), " bits"
|
||||||
|
echo "direction entropy : STRAFE ", fmtF(dirEntS,2), " vs TFIL ", fmtF(dirEntT,2), " bits"
|
||||||
|
echo "max |autocorr| (20..60) : STRAFE ", fmtF(autocorrPeak(sf.signs,20,60).r,2),
|
||||||
|
" at lag ", autocorrPeak(sf.signs,20,60).lag,
|
||||||
|
" vs TFIL ", fmtF(autocorrPeak(tfil.signs,20,60).r,2),
|
||||||
|
" at lag ", autocorrPeak(tfil.signs,20,60).lag
|
||||||
|
echo "mean |turnRate| : STRAFE ", fmtF(meanF(sf.turn.mapIt(abs(it))),2),
|
||||||
|
" vs TFIL ", fmtF(meanF(tfil.turn.mapIt(abs(it))),2), " deg/tick"
|
||||||
|
echo "mean |speed| : STRAFE ", fmtF(meanF(sf.absSpeed),2),
|
||||||
|
" vs TFIL ", fmtF(meanF(tfil.absSpeed),2), " px/tick"
|
||||||
Reference in New Issue
Block a user