## 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 is a strafe-specific RETUNE of that field (bullet 20/10, ## corridor 10, wall 15/5, pillar off — see the defaults block below), not the ## shipped TFIL field: with the shipped shape a safe tile existed on only 36.6% ## of picks (j108 Gate A). The time-indexed bullet model + pillar-free default ## are still reused, 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. ## ## ── Range control: a small TILT of the strafe line (j111) ──────────────────── ## Motion exactly perpendicular to the threat does not change the distance to ## the enemy, so pure strafe holds range BY CONSTRUCTION. To honour the owner's ## "we still need to try to go near the optimal distance", the line is tilted: ## ## lineAngle = threat + 90 + appliedTilt ## tilt = clamp(gain * (distance - TR_STRAFE_RANGE) beyond the dead band) ## ## Inside `±TR_STRAFE_RANGE_TOL` around `TR_STRAFE_RANGE` the tilt is EXACTLY 0, ## i.e. today's pure perpendicular strafe; that is meant to be the common case. ## Outside it the line leans so that one of its two ends is closer to the enemy ## (too far) or farther from it (too close). Because `threat` is the enemy ## bearing when the sky is clear but roughly its OPPOSITE when a bullet is in ## flight, the tilt's sign is aligned to the enemy bearing first, so a positive ## `tilt` always leans the SAME physical way. The magnitude is clamped to ## `±TR_STRAFE_TILT_MAX`, so the body still barely turns. ## ## A tilt ALONE cannot change the range: the picker chooses a random tile on ## BOTH ends of the line, so the radial drift averages to zero. The mover ## therefore also PREFERS the line end whose tile reduces |distance - target|, ## with a probability that grows with |tilt| (0.5 = no preference inside the ## dead band, up to 0.9 when the error is at `TILT_MAX`). Both ends stay ## possible, so the reversal randomness the pattern gun feeds on survives. ## ## ── Corner stall: always leave a corner (j111) ────────────────────────────── ## Candidate tiles outside the arena used to be silently skipped; when NONE ## survived, `targetValid` went false and the mover "kept driving on the last ## sign". In a corner whose outward direction was that sign the bot oscillated ## inside a tile forever (the owner's "goes straight to a corner and never come ## back"). Three defenses now guarantee an escape: ## 1. a DEGENERATE line (<= 1 in-arena candidate) falls back to a small radial ## search for the coolest in-arena tile and COMMITS the sign, so a ## near-perpendicular target cannot flip it back and forth; ## 2. a commanded move that produces no displacement for `StuckFlipTicks` ## ticks flips the sign; ## 3. the `[strafe] WARNING` line fires for both cases so the GUI log shows it. ## ## ── Curved wings + guaranteed escape (j112) ──────────────────────────────── ## The candidate set used to be the straight 1-D line through the bot. A bounded ## segment always ends at a wall, so the bot was FORCED into an edge/corner; and ## the all-hot fallback took the lowest `pathMaxHeat`, whose gradient points AT ## the wall (the shortest path has the least wall exposure). The line is now an ## ADAPTIVE PARABOLA with the vertex on the bot: ## ## point(y) = bot + yhat * y + xhat * kappa(y) * y^2 ## ## `yhat` is the strafe axis, `xhat` is the unit vector AWAY from the nearest ## wall(s). `kappa` grows as the wall approaches and saturates at ## `TR_STRAFE_KAPPA`; every wing point is clamped inside `TR_STRAFE_WALL_SAFE`, ## so the wing FLATTENS and runs parallel to the wall instead of touching it. In ## OPEN SPACE `kappa == 0` and the wing is exactly the old straight line. The ## wing's chord at the reach tilts the heading band toward the interior ## (`atan(kappa*reach)`, capped by `TR_STRAFE_WING_MAX`); the body still only ## turns slowly to follow that tangent, never to face the target. ## ## ESCAPE: when every candidate is over threshold AND the bot is within ## `TR_STRAFE_WALL_MARGIN`, the picker ranks by the DESTINATION (farthest from ## the wall, then coolest tile) and commands the sign whose velocity has a ## positive component along the wall-away normal. Because that sign is re-asserted ## EVERY tick, `speed * heading . away >= 0` while escape is active: the ## clearance can never fall. The `[strafe]` log marks it `mode=escape`. ## ## ── 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_RANGE 325.0 target enemy distance (px); mid of the 300-350 band ## TR_STRAFE_RANGE_TOL 25.0 dead-band half-width (px): tilt = 0 inside ## TR_STRAFE_TILT_MAX 15.0 max line tilt off the perpendicular (deg) ## TR_STRAFE_TILT_GAIN 0.10 deg of tilt per px of error BEYOND the band ## TR_STRAFE_HEAT_GRID 1 draw the full heat grid (0 = hide it) ## TR_STRAFE_BULLET_CORE 20.0 lava per bullet-overlapping tile (retune) ## TR_STRAFE_BULLET_AURA 10.0 lava for aura ring tiles (retune) ## TR_STRAFE_CORRIDOR_HEAT 10.0 lava per corridor tile (== threshold) ## TR_STRAFE_WALL_HOTNESS 15.0 peak wall radiance (retune) ## TR_STRAFE_WALL_RADIANCE 5.0 wall radiance falloff (retune) ## TR_STRAFE_KAPPA 0.0025 wing curvature at the wall (1/px; 0 = straight) ## TR_STRAFE_WALL_MARGIN 108.0 px range over which the wing bends ## TR_STRAFE_WING_MAX 30.0 cap on the wing's line tilt (deg) ## TR_STRAFE_WALL_BIAS 0.35 P(prefer a tile farther from the wall) ## TR_STRAFE_WALL_SAFE 24.0 px a wing point never lands nearer ## TR_STRAFE_ESCAPE 1 the all-hot guaranteed wall escape ## ## ── Pick quality on screen and in the log (j132) ──────────────────────────── ## The chosen tile's QUALITY used to be invisible: the overlay graded candidates ## with a binary colour and the `[strafe]` line never printed the chosen tile's ## heat, so a comfortably-safe pick could not be told from a marginal one. ## Both now carry the VALUE of the UNCHANGED safety rule `pathHeat <= 10`: ## * the `[strafe]` line gains `heat=/10.0 alt= ## ok=<1|0>` (see `strafeHeatText`); ## * the chosen tile gets a thick green->yellow->red border keyed to how close ## `h` is to the threshold, with `h` printed ON the tile, independent of ## `TR_STRAFE_HEAT_GRID` (so it is legible with the grid hidden); ## * candidates are stroked with the same ramp and, when the grid is hidden, ## labelled with their path heat, so the overlay reads on its own. ## This is display/logging only: `PathDangerThreshold` and every pick are ## byte-identical. ## ## 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, toLowerAscii import std/strformat import gun_harness/gun_interface import movement_harness/movement_interface import movement_harness/fire_tracker 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) ## ── Heat-shape defaults: the RETUNE, not the shipped TFIL field ───────────── ## STRAFE deliberately runs a DIFFERENT default heat shape from the shipped ## mover. The shipped field (corridor 20, wall 30/10) left a safe tile on the ## perpendicular line on only 36.6% of picks (j108 Gate A: 63.4% fallback, mean ## 3.70 safe candidates); this retune raises that to 75.4% (j110 Gate A: 24.6% ## fallback, mean 11.17 safe candidates). It is a deliberate middle ground: the ## bullet's own core MUST be dangerous (defect 2), and that by construction ## costs some safe tiles versus a field whose bullet core sat below threshold. ## BulletCore 20 / BulletAura 10 — a bullet's OWN heat (20) is now ABOVE ## PathDangerThreshold (10), so the bullet itself is the danger. With the ## shipped 10/5 a bullet core sat exactly ON the threshold and was ## therefore NEVER dangerous on its own; it only ever bit through its ## corridor. This is the ring mover's retune, adopted here. ## CorridorHeat 10 — exactly the threshold, so one corridor tile alone still ## cannot make a path unsafe (the rule is <= threshold); corridors nudge. ## WallHotness 15 / WallRadiance 5 — only the outer wall ring is a hard threat. ## Pillar 0/0 — off, exactly like the shipped default (imported globals). ## All five are env-overridable per run via the TR_STRAFE_* names below. const BulletCoreDefault = 20.0 ## lava per bullet-overlapping tile (retune) const BulletAuraDefault = 10.0 ## lava for aura ring tiles (retune) 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 = 10.0 ## corridor heat (== PathDangerThreshold) const WallHotnessDefault = 15.0 ## wall radiance peak (retune) const WallRadianceDefault = 5.0 ## wall radiance falloff (retune) ## Heat shape is override-able so the shipped field and the retune can be ## compared on one binary. The DEFAULTS are the retune (see the block above); ## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do ## not touch this field and vice versa. var StrafeBulletCore* = BulletCoreDefault StrafeBulletAura* = BulletAuraDefault 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 # ── pick-quality display (j132) ────────────────────────────────────────────── proc strafeHeatSafe*(h: float): bool {.inline.} = ## The picker's safety rule as a predicate, so the log and the overlay grade a ## tile EXACTLY the way the picker judges it. Unchanged rule: `<= 10`. h <= PathDangerThreshold proc heatRamp(h: float): Color = ## GREEN at or below half the threshold (comfortably safe), YELLOW exactly AT ## the threshold (marginal), RED above it (saturating at twice it). A cue ## only: the picker's rule stays `strafeHeatSafe`, so this can never admit or ## reject a tile. let t = if PathDangerThreshold > 0.0: h / PathDangerThreshold else: 0.0 if t <= 0.5: fromHex("#00DD00") elif t <= 1.0: fromRgb(uint8(255.0 * (t - 0.5) * 2.0), 255'u8, 0'u8) else: fromRgb(255'u8, uint8(255.0 * clamp(2.0 - t, 0.0, 1.0)), 0'u8) proc strafeHeatText*(heat, alt: float): string = ## The display fields appended to the `[strafe]` line: the chosen tile's path ## heat against the threshold, the coolest OTHER candidate (`-` = none left on ## the table), and the safe/over label. `heat=7.3/10.0 alt=9.8 ok=1` reads as ## "7.3 of the 10.0 allowed, the best tile passed up was 9.8 (marginal), and ## 7.3 is safe". One line, greppable: `grep -o 'heat=[0-9.]*/[0-9.]*'`. let altTxt = if alt < 0.0: "-" else: fmt"{alt:.1f}" let okTxt = if strafeHeatSafe(heat): "ok=1" else: "ok=0" fmt"heat={heat:.1f}/{PathDangerThreshold:.1f} alt={altTxt} " & okTxt # ── 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 proc getEnvBool(name: string, default: bool): bool = let s = getEnv(name, "").strip().toLowerAscii() if s.len == 0: return default s in ["1", "true", "on", "yes"] const DefaultStrafeBand = 20.0 DefaultStrafeSpread = 1 DefaultStrafeReach = 144.0 DefaultStrafeDwellMin = 6 DefaultStrafeDwellMax = 20 ## Range control (j111). Default = 325 px, the midpoint of the owner's ## stated 300-350 px band, chosen after watching live battles. ## History: j111 shipped 200.0 (= TR_POWER_FAR_DIST, where the power policy ## stops treating the enemy as close). The owner moved it to 300-350, which is ## also the direction the one piece of hard evidence points - j107 measured ## that drifting 25-30 px CLOSER made damage/run AND round wins WORSE. ## This is NOT a proven optimum: it is a knob with a band. A/B it before ## claiming anything, because our hit rate actually PEAKS around 100-200 px. DefaultStrafeRange = 325.0 DefaultStrafeRangeTol = 25.0 DefaultStrafeTiltMax = 15.0 DefaultStrafeTiltGain = 0.10 ## Curved wings + wall escape (j112). In OPEN SPACE kappa is 0 and the wing ## is exactly the straight line above; the correction exists only near a wall. DefaultStrafeKappa = 0.0025 ## wing curvature AT the wall (1/px) DefaultStrafeWallMargin = 108.0 ## px: range over which the wing bends DefaultStrafeWingMax = 30.0 ## deg: cap on the wing's line tilt DefaultStrafeWallBias = 0.35 ## P(prefer a tile farther from the wall) DefaultStrafeWallSafe = 24.0 ## px: a wing point never lands closer ## Stuck detector: a commanded move with < 0.5 px displacement this many ticks ## in a row flips the sign. Small enough to look instant, large enough to ride ## out the server applying the first command a tick late. const StuckFlipTicks = 5 var StrafeBand* = DefaultStrafeBand StrafeSpread* = DefaultStrafeSpread StrafeReach* = DefaultStrafeReach StrafeDwellMin* = DefaultStrafeDwellMin StrafeDwellMax* = DefaultStrafeDwellMax StrafeLog* = false StrafeRange* = DefaultStrafeRange StrafeRangeTol* = DefaultStrafeRangeTol StrafeTiltMax* = DefaultStrafeTiltMax StrafeTiltGain* = DefaultStrafeTiltGain StrafeKappa* = DefaultStrafeKappa StrafeWallMargin* = DefaultStrafeWallMargin StrafeWingMax* = DefaultStrafeWingMax StrafeWallBias* = DefaultStrafeWallBias StrafeWallSafe* = DefaultStrafeWallSafe StrafeEscape* = true ## ── fire-detection fix (j133) ────────────────────────────────────────── ## Corrects the enemy energy delta for the two SERVER effects that ## contaminate it before deciding whether a fire happened, and SPLITS a ## too-large drop instead of silently rejecting it: ## * `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an enemy ## bullet hits US the SHOOTER gains `3*power`, which hides the `power` ## the enemy spent firing the same tick (a net >= 0 delta reads as "no ## fire"). `noteEnemyBulletHit` adds the bonus back. ## * our own bullet damaging the enemy the same tick inflates the drop ## above 3.0 and gets the enemy's own shot rejected. `noteDamageDealt` ## subtracts it. ## MEASURED on 70 recorded battles (67065 true enemy fires): catches ## 98.888% of enemy fires with the knob OFF and 100.000% with it ON. ## j134: the detector now lives ONCE in `movement_harness/fire_tracker.nim` ## and every mover calls it. This flag is STRAFE's local gate: it is ON only ## when BOTH `TR_STRAFE_FIRE_FIX` and the global `TR_FIRE_FIX` are on, so ## either knob set to an off value restores the shipped detector exactly. StrafeFireFix*: bool = true ## TEMPORARY j134 diagnostic (TASK B): when `TR_FIRE_DIAG` is set, print one ## `[firediag] READ tick=…` line per enemy energy reading so the event/reading ## tick alignment can be checked live. OFF by default; observability only. StrafeFireDiag*: bool = false ## GUI: draw the full lava field (every non-zero tile, value-labelled) the ## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the ## strafe overlays can be read on their own. StrafeHeatGrid* = true proc loadStrafeHeatEnv*() = ## Re-read the heat-shape overrides. Exposed so a gate can restore the default ## field after temporarily retuning it on the SAME process. StrafeBulletCore = getEnvFloat("TR_STRAFE_BULLET_CORE", BulletCoreDefault) StrafeBulletAura = getEnvFloat("TR_STRAFE_BULLET_AURA", BulletAuraDefault) 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") StrafeRange = max(0.0, getEnvFloat("TR_STRAFE_RANGE", DefaultStrafeRange)) StrafeRangeTol = max(0.0, getEnvFloat("TR_STRAFE_RANGE_TOL", DefaultStrafeRangeTol)) StrafeTiltMax = max(0.0, min(80.0, getEnvFloat("TR_STRAFE_TILT_MAX", DefaultStrafeTiltMax))) StrafeTiltGain = max(0.0, getEnvFloat("TR_STRAFE_TILT_GAIN", DefaultStrafeTiltGain)) StrafeKappa = max(0.0, getEnvFloat("TR_STRAFE_KAPPA", DefaultStrafeKappa)) StrafeWallMargin = max(0.0, getEnvFloat("TR_STRAFE_WALL_MARGIN", DefaultStrafeWallMargin)) StrafeWingMax = max(0.0, min(89.0, getEnvFloat("TR_STRAFE_WING_MAX", DefaultStrafeWingMax))) StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias))) StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe)) StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true) StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true) and getEnvBool("TR_FIRE_FIX", true) StrafeFireDiag = existsEnv("TR_FIRE_DIAG") StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true) 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] # ── decision state ── targetX*, targetY*: float ## chosen tile centre (world coords) targetValid*: bool targetLava: float ## heat at the chosen tile when picked # ── pick quality (j132): display/log only, never read by a decision ── targetPathHeat*: float ## path max heat to the chosen tile, THIS tick pickPathHeat*: float ## what the picker measured for the chosen tile targetBestAlt*: float ## coolest OTHER candidate's path heat (-1 none) 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) # ── range control (j111) ── rangeDist*: float ## enemy distance this tick (-1 = unknown) rangeTilt*: float ## applied line tilt this tick (deg) # ── curved wings + wall escape (j112) ── wallDist*: float ## distance from the bot to the nearest wall (px) kappa*: float ## applied wing curvature this tick (1/px) wingTilt*: float ## applied line tilt toward the wall-away normal (deg) escapeActive*: bool ## this tick runs on the wall-away escape bearing escapeBearing*: float ## bearing of the wall-away normal when escaping wallEscapePicks*: int ## picks forced inward by the all-hot escape escapeModeTicks*: int ## ticks the escape bearing was in effect lastMode*: string ## "pick" | "fallback" | "escape" | "radial" # ── fire detection (j134): the shared enemy-fire tracker ── fire: FireTracker ## ONE detector for every mover (movement_harness/fire_tracker) # ── 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 escapePicks*: int ## picks where the line was degenerate -> radial escape cornerGuards*: int ## ticks the line was projected off the arena corner stuckTicks*: int ## consecutive commanded ticks with no displacement stuckFlips*: int ## sign flips forced by the stuck detector 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, fire: initFireTracker()) 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 = m.fire.prevEnergyGet(id) proc prevEnergySet(m: var StrafeModule, id: int, energy: float) = ## Kept for the guard test / callers that seed an energy reading directly; ## delegates to the shared tracker. m.fire.prevEnergySet(id, 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.fire.reset() m.targetValid = false m.targetLava = 0.0 m.targetPathHeat = 0.0 m.pickPathHeat = 0.0 m.targetBestAlt = -1.0 m.dwell = 0 m.dir = 1.0 m.bandOffset = 0.0 m.lineDir = 0.0 m.rangeDist = -1.0 m.rangeTilt = 0.0 m.wallDist = 1e9 m.kappa = 0.0 m.wingTilt = 0.0 m.escapeActive = false m.escapeBearing = 0.0 m.wallEscapePicks = 0 m.escapeModeTicks = 0 m.lastMode = "" m.callCount = 0 m.picks = 0 m.lastPickCall = 0 m.lastCandCount = 0 m.lastSafeCount = 0 m.fallbackPicks = 0 m.escapePicks = 0 m.cornerGuards = 0 m.stuckTicks = 0 m.stuckFlips = 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 spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo, power: float) = ## One tracked bullet/wave: the enemy's CURRENT (scanned) position as origin, ## direction guessed at our predicted position, power as passed in. let speed = 20.0 - 3.0 * power 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) # j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`, # default 0 = untouched). See `movement_harness/fire_tracker.nim`. var gx = ei.x var gy = ei.y if FireLag > 0: gx += (speed * cos(heading)) * FireLag.float gy += (speed * sin(heading)) * FireLag.float m.bullets.add TrackedBullet( originX: ei.x, originY: ei.y, x: gx, y: gy, velX: speed * cos(heading), velY: speed * sin(heading), power: power, alive: true, age: 0) proc noteEnemyBulletHit*(m: var StrafeModule, power: float) = ## SERVER FACT (`rules.kt` `BULLET_HIT_ENERGY_GAIN_FACTOR = 3`): when an ## enemy bullet hits US, the SHOOTER's energy RISES by `3 * power`. That rise ## is folded into the enemy energy delta we read this tick and can MASK the ## `power` the enemy spent firing in the same tick (net delta >= 0 reads as ## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so ## `detectFires` can add the bonus back before classifying the delta. ## No-op when the fix is off (shipped detector preserved). if StrafeFireFix: m.fire.noteEnemyBulletHit(power) proc noteDamageDealt*(m: var StrafeModule, damage: float) = ## The mirror contamination: OUR bullet damaging the enemy this tick adds ## `damage` to the enemy's energy drop, which can push it above the 3.0 power ## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test. ## ModularBot forwards `onBulletHit`'s `e.damage` here. ## No-op when the fix is off. if StrafeFireFix: m.fire.noteDamageDealt(damage) proc detectFires(m: var StrafeModule, ws: WorldState) = ## The shared tracker does the delta correction + split; STRAFE supplies its ## shipped window (0.09 .. 3.01) and its own wave geometry. for ei in ws.enemies: if StrafeFireDiag: let raw = m.fire.prevEnergyGet(ei.id) - ei.energy echo "[firediag] READ tick=", ws.tick, " id=", ei.id, " raw=", raw, " bonus=", m.fire.hitBonusPending, " dealt=", m.fire.dealtPending for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix): m.spawnTrackedWave(ws, ei, p) if StrafeFireDiag and m.bullets.len > 0: # Ghost-vs-observer probe: the tick we DETECTED the fire, our own # position (the timeline anchor) and the ghost's DRAWN position. let b = m.bullets[^1] let sp = 20.0 - 3.0 * p echo "[firediag] SPAWN tick=", ws.tick, " sx=", ws.selfX, " sy=", ws.selfY, " gx=", b.x, " gy=", b.y, " p=", p, " eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp m.fire.endScan() 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] += StrafeBulletCore * bMag * heatDecay(along / bSpeed) elif d2 <= auraR * auraR: let along = dx * bUx + dy * bUy m.lava[row * m.cols + col] += StrafeBulletAura * 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.. enemy, degrees dist: float ex, ey: float proc enemyRange(ws: WorldState): RangeInfo = ## Current target enemy if set, else the first tracked enemy. if ws.enemyX != 0.0 or ws.enemyY != 0.0: let dx = ws.enemyX - ws.selfX let dy = ws.enemyY - ws.selfY return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI, dist: hypot(dx, dy), ex: ws.enemyX, ey: ws.enemyY) for ei in ws.enemies: let dx = ei.x - ws.selfX let dy = ei.y - ws.selfY return RangeInfo(found: true, bearing: arctan2(dy, dx) * 180.0 / PI, dist: hypot(dx, dy), ex: ei.x, ey: ei.y) RangeInfo(found: false) # ── target picking ─────────────────────────────────────────────────────────── type Cand = object col, row: int x, y: float pathHeat: float destHeat: float ## heat of the DESTINATION tile (not the path) clearance: float ## distance from the tile centre to the nearest wall (px) along: float ## signed offset along the line (+ = forward of lineForward) proc wallClearance(m: StrafeModule, x, y: float): float {.inline.} = ## Distance from (x,y) to the nearest wall. min(min(x, m.arenaWidth - x), min(y, m.arenaHeight - y)) proc wallAwayDir(m: StrafeModule, x, y: float): tuple[ax, ay: float] = ## Unit vector AWAY from the nearest wall(s). Sums the inward normal of every ## wall within `TR_STRAFE_WALL_MARGIN`, so a CORNER yields the diagonal and ## the away direction has a non-negative dot with every binding wall's outward ## gradient. (0,0) when no wall is within the margin == open space. var ax = 0.0 var ay = 0.0 let mm = max(1.0, StrafeWallMargin) if x < mm: ax += (mm - x) / mm if m.arenaWidth - x < mm: ax -= (mm - (m.arenaWidth - x)) / mm if y < mm: ay += (mm - y) / mm if m.arenaHeight - y < mm: ay -= (mm - (m.arenaHeight - y)) / mm let n = hypot(ax, ay) if n > 1e-9: (ax / n, ay / n) else: (0.0, 0.0) proc radialEscape(m: StrafeModule, ws: WorldState): tuple[found: bool, col, row: int, x, y, pathHeat: float] = ## The corner defense: when the strafe line has no usable in-arena tile, ## search a small radial neighbourhood and return the COOLEST in-arena tile ## (least path heat, then least tile lava, then nearest). Near a corner the ## wall radiance makes the interior the coolest, so this always points back ## into the arena. var bestPh = Inf var bestLava = Inf var bestD2 = Inf let (bc, br) = m.tileAt(ws.selfX, ws.selfY) const R = 4 for dr in -R..R: for dc in -R..R: if dc == 0 and dr == 0: continue let c = bc + dc let r = br + dr if c < 0 or c >= m.cols or r < 0 or r >= m.rows: continue let cx = m.marginX + (c.float + 0.5) * GridSize let cy = m.marginY + (r.float + 0.5) * GridSize let ph = m.pathMaxHeat(ws.selfX, ws.selfY, cx, cy) let lv = m.lavaAt(c, r) let d2 = (dc * dc + dr * dr).float if ph < bestPh - 1e-9 or (abs(ph - bestPh) <= 1e-9 and (lv < bestLava - 1e-9 or (abs(lv - bestLava) <= 1e-9 and d2 < bestD2))): bestPh = ph; bestLava = lv; bestD2 = d2 result = (found: true, col: c, row: r, x: cx, y: cy, pathHeat: ph) proc pickTarget(m: var StrafeModule, ws: WorldState, lineForward: float, rng: RangeInfo, tiltMag, kappa: 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) let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY) let nearWall = m.wallDist < StrafeWallMargin # `wings` is false only when BOTH wings and escape are off; then the candidate # geometry is EXACTLY the pre-j112 straight line, so the gate's "before" arm # reproduces the old mover bit for bit (open-space parity). let wings = StrafeKappa > 0.0 or StrafeEscape m.escapeActive = false var cands: seq[Cand] for k in 1..kmax: for s in [-1.0, 1.0]: let along = s * k.float * GridSize let off = kappa * along * along # parabola, vertex on the bot for j in -spread..spread: var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize) var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize) if wings: # SAFETY MARGIN: clamp every wing point inside it, so the wing # flattens and runs parallel to the wall instead of touching it. wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) elif 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), destHeat: m.lavaAt(c, r), clearance: m.wallClearance(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] var mode = "pick" if safe.len > 0: pool = safe elif cands.len > 0: if StrafeEscape and nearWall: # ── GUARANTEED ESCAPE (B) ───────────────────────────────────────────── # Every tile is over threshold. The old rule minimised `pathMaxHeat`, # but near a wall EVERY path is dominated by the same wall tile and the # SHORTEST path has the least wall exposure, so the "least hot" tile was # always the adjacent one and led further along the wall. Rank by the # DESTINATION instead: farthest from the wall first, then coolest tile. var sorted = cands for i in 1..= 0 and (sorted[j].clearance < key.clearance or (sorted[j].clearance == key.clearance and sorted[j].destHeat > key.destHeat)): sorted[j + 1] = sorted[j] dec j sorted[j + 1] = key pool = @[sorted[0]] mode = "escape" m.escapeActive = true if awX != 0.0 or awY != 0.0: m.escapeBearing = arctan2(awY, awX) * 180.0 / PI inc m.wallEscapePicks else: # 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 and nearWall and pool.len > 1: var improved: seq[Cand] for c in pool: if c.clearance > m.wallDist + 1.0: improved.add c if improved.len > 0 and rand(1.0) < StrafeWallBias: pool = improved # ── range control: prefer the line end that closes the range error ── # A tilt alone cannot move the range (the picker chooses BOTH ends at # random); this is what turns the tilt into a net radial drift. The bias is # soft (the far end stays possible) so the reversal randomness survives. if rng.found and tiltMag > 0.0 and StrafeTiltMax > 0.0: let curErr = abs(rng.dist - StrafeRange) var approach: seq[Cand] for c in pool: let nd = hypot(rng.ex - c.x, rng.ey - c.y) if abs(nd - StrafeRange) < curErr: approach.add c if approach.len > 0 and approach.len < pool.len: let p = clamp(0.5 + 0.4 * (tiltMag / StrafeTiltMax), 0.0, 0.9) if rand(1.0) < p: pool = approach 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 m.lastMode = mode # ── pick quality (j132): the number the picker measured, plus the coolest # candidate it did NOT take (what it left on the table). Display/log only. m.pickPathHeat = pool[chosen].pathHeat m.targetBestAlt = -1.0 for c in cands: if c.col == pool[chosen].col and c.row == pool[chosen].row: continue if m.targetBestAlt < 0.0 or c.pathHeat < m.targetBestAlt: m.targetBestAlt = c.pathHeat # 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 if m.escapeActive and (awX != 0.0 or awY != 0.0): # GUARANTEE (B): pick the sign whose velocity has a POSITIVE component # AWAY from the wall. `hdot` = (heading . away); the speed is MaxSpeed*dir # along the heading, so dir = sign(hdot) makes (speed*heading . away) >= 0 # and the wall clearance cannot fall. let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad) m.dir = if hdot >= 0.0: 1.0 else: -1.0 elif escaped and m.lastCmdSign != 0.0 and abs(alongDot) < GridSize: # COMMIT: the escape tile is near-perpendicular to the heading, so letting # it set the sign re-creates the corner oscillation. Keep sliding. m.dir = m.lastCmdSign else: 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: echo fmt"[strafe] {mode} 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} " & fmt"dist={rng.dist:.0f} target={StrafeRange:.0f} tilt={m.rangeTilt:.1f} " & fmt"wall={m.wallDist:.0f} kappa={kappa:.4f} " & strafeHeatText(pool[chosen].pathHeat, m.targetBestAlt) & " " & fmt"mode={mode}" if escaped or safe.len == 0 or mode == "escape": echo fmt"[strafe] WARNING: no SAFE tile on the line " & fmt"(cands={cands.len}, clearance={pool[chosen].clearance:.0f}, " & fmt"destHeat={pool[chosen].destHeat:.1f}, mode={mode})" # ── 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.fire.prevEnergy = @[] for ei in ws.enemies: m.fire.prevEnergySet(ei.id, ei.energy) m.advanceBullets(ws.selfX, ws.selfY) m.detectFires(ws) m.buildHeat(ws) # ── threat axis -> perpendicular line, TILTED by the range error (j111) ── let threat = m.threatBearing(ws) let rng = enemyRange(ws) m.rangeDist = if rng.found: rng.dist else: -1.0 var tilt = 0.0 if rng.found and StrafeTiltMax > 0.0 and StrafeTiltGain > 0.0: let err = rng.dist - StrafeRange if abs(err) > StrafeRangeTol: let excess = (abs(err) - StrafeRangeTol) * (if err > 0.0: 1.0 else: -1.0) tilt = clamp(StrafeTiltGain * excess, -StrafeTiltMax, StrafeTiltMax) # `threat` is the enemy bearing when the sky is clear, but roughly its # OPPOSITE when it comes from an incoming bullet; align the tilt so a positive # `tilt` always leans the SAME physical way (toward the enemy when too far). var appliedTilt = tilt if tilt != 0.0 and rng.found and cos(wrap180(threat - rng.bearing) * DegToRad) > 0.0: appliedTilt = -tilt var lineAngle = threat + 90.0 + appliedTilt # ── curved wings (j112): kappa grows as the wall approaches, saturates at # TR_STRAFE_KAPPA, and is EXACTLY 0 in open space (the wing is then today's # straight line). The parabola's chord at the reach is tilted toward the # wall-away normal by atan(kappa*reach), capped by TR_STRAFE_WING_MAX, so the # body follows the arc's local tangent instead of turning to face the target. m.wallDist = m.wallClearance(ws.selfX, ws.selfY) let prox = if StrafeWallMargin > 0.0: clamp((StrafeWallMargin - m.wallDist) / StrafeWallMargin, 0.0, 1.0) else: 0.0 m.kappa = StrafeKappa * prox let (awX, awY) = m.wallAwayDir(ws.selfX, ws.selfY) let wingTilt = min(arctan(m.kappa * StrafeReach) * 180.0 / PI, StrafeWingMax) m.wingTilt = wingTilt # ── corner guard: the line must never point OUT of the arena on BOTH ends ── # If both ends are outside, project away the outward component of the line # direction so it becomes parallel to the nearest wall. Then at least one # candidate tile is always in the arena and the bot slides along the wall # instead of pressing into the corner -- the deterministic half of the stall # fix; the radial escape in `pickTarget` is the backup for the rare case where # even a projected line has no usable tile. block: let ux0 = cos(lineAngle * DegToRad) let uy0 = sin(lineAngle * DegToRad) let r = StrafeReach let x1 = ws.selfX + ux0 * r let y1 = ws.selfY + uy0 * r let x2 = ws.selfX - ux0 * r let y2 = ws.selfY - uy0 * r let out1 = x1 < 0.0 or x1 >= m.arenaWidth or y1 < 0.0 or y1 >= m.arenaHeight let out2 = x2 < 0.0 or x2 >= m.arenaWidth or y2 < 0.0 or y2 >= m.arenaHeight if out1 and out2: var gx = ux0 var gy = uy0 const WallMargin = 2.0 * GridSize if ws.selfX < WallMargin and gx < 0.0: gx = 0.0 if ws.selfX > m.arenaWidth - WallMargin and gx > 0.0: gx = 0.0 if ws.selfY < WallMargin and gy < 0.0: gy = 0.0 if ws.selfY > m.arenaHeight - WallMargin and gy > 0.0: gy = 0.0 if abs(gx) < 1e-6 and abs(gy) < 1e-6: # Dead corner: run parallel to whichever wall is nearest. let mx = min(ws.selfX, m.arenaWidth - ws.selfX) let my = min(ws.selfY, m.arenaHeight - ws.selfY) if mx <= my: gx = 0.0; gy = 1.0 else: gx = 1.0; gy = 0.0 lineAngle = arctan2(gy, gx) * 180.0 / PI appliedTilt = 0.0 # the line is wall-parallel now; no range tilt on it inc m.cornerGuards # Apply the wing tilt AFTER the corner guard, so the curve survives in a # corner: the wall-parallel line is leaned back toward the interior. if wingTilt > 0.0 and (awX != 0.0 or awY != 0.0): let awayBearing = arctan2(awY, awX) * 180.0 / PI lineAngle += (if wrap180(awayBearing - lineAngle) >= 0.0: wingTilt else: -wingTilt) if m.escapeActive and m.wallDist >= StrafeWallMargin: m.escapeActive = false m.rangeTilt = appliedTilt 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, rng, abs(appliedTilt), m.kappa) else: dec m.dwell # ── pick quality (j132), display only: the chosen tile's LIVE path heat. # Recomputed every tick because the field moves with the bullets; the GUI # prints it ON the tile and the `[strafe]` line prints it at pick time. On a # pick tick it EQUALS `pickPathHeat`, because it is the same `pathMaxHeat` # call over the same field and the same position - asserted by # `common_libs/tests/measure_strafe_heat_display.nim`. m.targetPathHeat = if m.targetValid: m.pathMaxHeat(ws.selfX, ws.selfY, m.targetX, m.targetY) else: 0.0 # ── heading band: turn ONLY to stay on the line / arc tangent, never to the # target. In escape mode the band reference is the wall-away normal, so the # body slowly rotates inward until the sign can push us off the wall. ── var turnRate = 0.0 let mtr = 10.0 - 0.75 * abs(ws.selfSpeed) var bandRef = lineForward if m.escapeActive: bandRef = m.escapeBearing inc m.escapeModeTicks let dev = wrap180(ws.selfHeading - bandRef) if abs(dev) > StrafeBand: let targetHeading = bandRef + m.bandOffset turnRate = clamp(wrap180(targetHeading - ws.selfHeading), -mtr, mtr) # ── stuck detector: a commanded move that does not move us flips the sign ── if m.callCount > 0 and not jumped: let disp = hypot(ws.selfX - m.lastBotX, ws.selfY - m.lastBotY) if disp < 0.5: inc m.stuckTicks else: m.stuckTicks = 0 if m.stuckTicks >= StuckFlipTicks: m.dir = if m.dir >= 0.0: -1.0 else: 1.0 m.lastCmdSign = m.dir m.stuckTicks = 0 inc m.stuckFlips if StrafeLog: echo fmt"[strafe] WARNING: no displacement for {StuckFlipTicks} ticks " & fmt"-> flipping the sign (pressed into a corner/wall)" # ── move by sign only ── # ESCAPE (B): keep the commanded sign aligned to the wall-away normal on # EVERY tick, not only at pick time. If the heading crosses the normal during # the dwell the sign flips with it, so `speed * heading . away` is ALWAYS # >= 0: the clearance cannot fall while escaping. That is the guarantee, and # it is a sign flip (setForward(±)), never a 180-degree turn. if m.escapeActive and (awX != 0.0 or awY != 0.0): let hdot = cos(wrap180(ws.selfHeading - m.escapeBearing) * DegToRad) m.dir = if hdot >= 0.0: 1.0 else: -1.0 # The picker now guarantees a target whenever any in-arena tile exists (the # radial escape), so "keep the last sign" is only the truly boxed-in case -- # and the stuck detector above still flips out of that. 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: # The WHOLE lava field, drawn exactly as TFIL draws it: every non-zero tile # stroked in a yellow->orange->red ramp scaled by the field maximum and # labelled with its integer value. This is the picture the owner is used to # from TFIL; the strafe overlays below draw ON TOP of it. `TR_STRAFE_HEAT_GRID=0` # hides it. if StrafeHeatGrid: var maxLava = 0.0 for v in m.lava: if v > maxLava: maxLava = v setFont("Arial", 10.0) for row in 0.. 0.0: val / maxLava else: 0.0 let heatColor = fromRgb(255'u8, uint8(255.0 * (1.0 - t)), 0'u8) let x0 = m.marginX + col.float * GridSize let y0 = m.marginY + row.float * GridSize setStrokeColor(heatColor) setStrokeWidth(1.0) drawRectangle(x0, y0, GridSize, GridSize) setFillColor(heatColor) drawText($int(val), x0 + 12.0, y0 + 22.0) 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) # ── CURVED WINGS (j112): the path actually offered to the picker. # point(y) = bot + yhat*y + xhat*kappa*y^2, clamped to the safety margin so # the wing flattens and runs parallel to the wall instead of touching it. # In open space kappa == 0, so this collapses onto the cyan line above. if m.kappa > 0.0 and (awX != 0.0 or awY != 0.0): setStrokeColor(fromHex("#FFA500")) setStrokeWidth(3.0) for sgn in [-1.0, 1.0]: var prevX = ws.selfX var prevY = ws.selfY for si in 1..24: let y = sgn * StrafeReach * (si.float / 24.0) let off = m.kappa * y * y var wx = ws.selfX + ux * y + awX * off var wy = ws.selfY + uy * y + awY * off wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) drawLine(prevX, prevY, wx, wy) prevX = wx; prevY = wy # 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 let off = m.kappa * along * along for j in -spread..spread: var wx = ws.selfX + ux * along + awX * off + px * (j.float * GridSize) var wy = ws.selfY + uy * along + awY * off + py * (j.float * GridSize) if m.kappa > 0.0: wx = clamp(wx, StrafeWallSafe, m.arenaWidth - StrafeWallSafe) wy = clamp(wy, StrafeWallSafe, m.arenaHeight - StrafeWallSafe) elif 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 # GRADE the candidate by the SAME number the picker uses: the max heat # on the straight path from the bot (the old binary looked at the # tile's own lava, which is not the safety rule). let ph = m.pathMaxHeat(ws.selfX, ws.selfY, x0 + GridSize * 0.5, y0 + GridSize * 0.5) setStrokeColor(heatRamp(ph)) setStrokeWidth(if strafeHeatSafe(ph): 1.5 else: 1.0) drawRectangle(x0, y0, GridSize, GridSize) # With the full heat grid HIDDEN (`TR_STRAFE_HEAT_GRID=0`) the # candidates carry their own number, so the STRAFE overlay reads by # itself; with the grid ON the grid already labels every tile. if not StrafeHeatGrid: setFillColor(heatRamp(ph)) setFont("Arial", 10.0) drawText(fmt"{ph:.1f}", x0 + 3.0, y0 + 14.0) # ── chosen target tile (j132): its QUALITY, not just its position ──────── # A thick ramp-coloured border (green = comfortably safe, yellow = at the # threshold, red = over) plus the tile's path heat printed ON the tile over # a dark plate, so it stays legible whatever the fill colour is. The magenta # dot still marks the tile as the chosen target. Drawn independently of # `StrafeHeatGrid`, so the pick reads clearly with `TR_STRAFE_HEAT_GRID=0`. 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(heatRamp(m.targetPathHeat)) setStrokeWidth(3.5) drawRectangle(gx0, gy0, GridSize, GridSize) setFillColor(fromHex("#000000")) fillRectangle(gx0 + 1.0, gy0 + 18.0, GridSize - 2.0, 17.0) setFillColor(heatRamp(m.targetPathHeat)) setFont("Arial", 12.0) drawText(fmt"{m.targetPathHeat:.1f}", gx0 + 4.0, gy0 + 31.0) 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) # ── ESCAPE marker (j112): the wall-away ray + a label, so the user can see # the guaranteed-escape mode engage. ── if m.escapeActive: setStrokeColor(fromHex("#FFFFFF")) setStrokeWidth(3.0) drawLine(ws.selfX, ws.selfY, ws.selfX + awX * 110.0, ws.selfY + awY * 110.0) setFillColor(fromHex("#FF0000")) setFont("Arial", 15.0) drawText("ESCAPE", ws.selfX + 24.0, ws.selfY + 36.0) # ── range-control marker (j111) ── # A ray toward the enemy whose LENGTH is |distance - target| and whose # COLOUR encodes the wanted motion: green = too far (closing in), red = too # close (backing off), white text = the numbers on the log line. if rng.found: let err = rng.dist - StrafeRange let rb = rng.bearing * DegToRad setStrokeColor(if err >= 0.0: fromHex("#00FF88") else: fromHex("#FF4444")) setStrokeWidth(3.0) let rl = min(abs(err), 120.0) drawLine(ws.selfX, ws.selfY, ws.selfX + cos(rb) * rl, ws.selfY + sin(rb) * rl) setFillColor(fromHex("#FFFFFF")) setFont("Arial", 12.0) drawText(fmt"d={rng.dist:.0f} tgt={StrafeRange:.0f} tilt={appliedTilt:.1f} " & fmt"wall={m.wallDist:.0f} k={m.kappa:.4f}", ws.selfX + 22.0, ws.selfY - 22.0) # ── stuck marker (j111) ── if m.stuckTicks > 0: setStrokeColor(fromHex("#FF0000")) setStrokeWidth(3.0) drawCircle(ws.selfX, ws.selfY, 24.0) setFillColor(fromHex("#FF0000")) fillCircle(ws.selfX - 27.0, ws.selfY - 27.0, 5.0) # Heading band: the two ±band boundary rays (yellow) around the effective # reference (the arc line, or the wall-away bearing in escape mode). setStrokeColor(fromHex("#FFFF00")) setStrokeWidth(1.0) let bl = (bandRef - StrafeBand) * DegToRad let br2 = (bandRef + 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