From a50c0125d52148570123f33ea0e25aeff8f3f73b Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Fri, 25 Sep 2026 22:38:52 +0200 Subject: [PATCH] STRAFE movement: body pinned perpendicular to the threat, reversals by sign flip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ModularBot_garage/src/ModularBot.nim | 23 +- ModularBot_garage/src/env_report.nim | 27 +- common_libs/movements/strafe.nim | 740 +++++++++++++++++++++ common_libs/tests/measure_strafe_gates.nim | 322 +++++++++ 4 files changed, 1103 insertions(+), 9 deletions(-) create mode 100644 common_libs/movements/strafe.nim create mode 100644 common_libs/tests/measure_strafe_gates.nim diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 919f503..04c782a 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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, diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 3bd6b45..d22ea35 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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", diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim new file mode 100644 index 0000000..22d3588 --- /dev/null +++ b/common_libs/movements/strafe.nim @@ -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..= 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.. 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.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..= 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..= 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 diff --git a/common_libs/tests/measure_strafe_gates.nim b/common_libs/tests/measure_strafe_gates.nim new file mode 100644 index 0000000..6b64c8f --- /dev/null +++ b/common_libs/tests/measure_strafe_gates.nim @@ -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.. 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.. 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.. 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.. 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"