d21f7ce5f5
MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions, 1777 matched ghost spawns, both movers): the server dispatches a turn's fire AFTER our go() for that same turn, so a turn-T shot's energy drop first reaches our scan at turn T+1 — and a bullet takes its FIRST step during the turn it is fired, so the true bullet is already one whole bullet step (11-20 px) downrange. Both movers place the ghost at the SCANNED enemy position (where the bullet was born), so the whole ghost trajectory is the true one shifted one turn later and the arrival deadline is a full tick late. MEASURED: detection lag +1 tick on 100% of 1777 matched spawns; ghost-vs- observer displacement 19.06 px mean / 22.00 p90 (tfil) and 16.08 / 21.81 (strafe); arrival-deadline error 0.99 / 0.77 ticks. NOT a rendering artefact: the draw/advance order is correct (advanceBullets -> detectFires -> build). THE FIX: TR_FIRE_LAG (int, default 0 = today byte-for-byte) in the shared fire_tracker, applied by both movers at spawn: x = origin + dir*speed*lag. The deadline needs no separate change — both movers derive it from the ghost's own position, so a correct position gives a correct deadline. WITH IT: displacement 19.06 -> 5.37 px mean (the residue is the enemy's own <=8 px scan staleness) and the deadline error 0.99 -> 0.06 ticks. Guards: test_tfil_commit_env 77 -> 87 checks (default golden parity, exact n-step back-date, deadline shortens by exactly lag, junk/negative degrade to 0, reaped exactly one tick earlier); test_env_report + test_env_dotenv green. TR_FIRE_LAG registered in env_report + knownEnvNames + .env.example + docs/env_reference.md. Live A/B pre-registered in docs/movement_campaign.md (Batch 8) with its MDE stated up front; arms tools/ab/arms_fire_lag.txt. TR_FIRE_DIAG gains a per-round ROUND line (the tick->getTurn anchor) and a per-spawn SPAWN line (the ghost's drawn position).
1454 lines
70 KiB
Nim
1454 lines
70 KiB
Nim
## 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=<h>/10.0 alt=<best other candidate>
|
|
## 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..<m.lava.len: m.lava[i] = 0.0
|
|
|
|
# Bullet cores / auras (j105 time model: heat is a function of time-to-arrive).
|
|
for b in m.bullets:
|
|
let bx = b.x
|
|
let by = b.y
|
|
let (coreR, auraR) = bulletRadii(b.power)
|
|
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
|
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
|
|
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
|
|
let bMag = bulletMagScale(b.power)
|
|
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
|
|
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
|
|
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
|
|
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
|
|
for row in rowMin..rowMax:
|
|
for col in colMin..colMax:
|
|
let x0 = m.marginX + col.float * GridSize
|
|
let y0 = m.marginY + row.float * GridSize
|
|
let nearX = clamp(bx, x0, x0 + GridSize)
|
|
let nearY = clamp(by, y0, y0 + GridSize)
|
|
let dx = nearX - bx
|
|
let dy = nearY - by
|
|
let d2 = dx*dx + dy*dy
|
|
if d2 <= coreR * coreR:
|
|
let along = dx * bUx + dy * bUy
|
|
m.lava[row * m.cols + col] += 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..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let heat = max(0.0, StrafeWallHotness - col.float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - (m.cols-1-col).float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - row.float * StrafeWallRadiance) +
|
|
max(0.0, StrafeWallHotness - (m.rows-1-row).float * StrafeWallRadiance)
|
|
m.lava[row * m.cols + col] += heat
|
|
|
|
# Pillar radiance (imported globals; 0/0 = the shipped pillar-free default).
|
|
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
|
|
let pc1 = m.cols div 2
|
|
let pr0 = if m.rows mod 2 == 1: m.rows div 2 else: m.rows div 2 - 1
|
|
let pr1 = m.rows div 2
|
|
for row in 0..<m.rows:
|
|
for col in 0..<m.cols:
|
|
var minDist = int.high
|
|
for pcol in pc0..pc1:
|
|
for prow in pr0..pr1:
|
|
let d = max(abs(col - pcol), abs(row - prow))
|
|
if d < minDist: minDist = d
|
|
m.lava[row * m.cols + col] += max(0.0, PillarHotness - minDist.float * PillarRadiance)
|
|
|
|
proc pathMaxHeat(m: StrafeModule, x0, y0, x1, y1: float): float =
|
|
let dx = x1 - x0
|
|
let dy = y1 - y0
|
|
let dist = sqrt(dx*dx + dy*dy)
|
|
if dist < 0.1: return 0.0
|
|
let steps = max(1, int(dist / PathSampleStep))
|
|
for si in 0..steps:
|
|
let f = si.float / steps.float
|
|
let (c, r) = m.tileAt(x0 + dx * f, y0 + dy * f)
|
|
result = max(result, m.lavaAt(c, r))
|
|
|
|
# ── the threat axis ──────────────────────────────────────────────────────────
|
|
|
|
proc threatBearing(m: StrafeModule, ws: WorldState): float =
|
|
## Incoming bullet direction when a bullet is in flight (nearest one), else
|
|
## the enemy bearing.
|
|
var bestD = Inf
|
|
var found = false
|
|
for b in m.bullets:
|
|
let d = hypot(b.x - ws.selfX, b.y - ws.selfY)
|
|
if d < bestD:
|
|
bestD = d
|
|
result = arctan2(b.velY, b.velX) * 180.0 / PI
|
|
found = true
|
|
if found: return result
|
|
if ws.enemyX != 0.0 or ws.enemyY != 0.0:
|
|
return arctan2(ws.enemyY - ws.selfY, ws.enemyX - ws.selfX) * 180.0 / PI
|
|
for ei in ws.enemies:
|
|
return arctan2(ei.y - ws.selfY, ei.x - ws.selfX) * 180.0 / PI
|
|
return 0.0
|
|
|
|
# ── range: current enemy bearing / distance ──────────────────────────────────
|
|
|
|
type
|
|
RangeInfo = object
|
|
found: bool
|
|
bearing: float ## bot -> 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..<sorted.len:
|
|
let key = sorted[i]
|
|
var j = i - 1
|
|
while j >= 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..<sorted.len:
|
|
let key = sorted[i]
|
|
var j = i - 1
|
|
while j >= 0 and sorted[j].pathHeat > key.pathHeat:
|
|
sorted[j + 1] = sorted[j]
|
|
dec j
|
|
sorted[j + 1] = key
|
|
let take = min(2, sorted.len)
|
|
for i in 0..<take: pool.add sorted[i]
|
|
mode = "fallback"
|
|
inc m.fallbackPicks
|
|
|
|
# ── corner defense: a degenerate line (< 2 in-arena candidates) escapes ──
|
|
let degenerate = cands.len <= 1
|
|
var escaped = false
|
|
if degenerate:
|
|
let esc = m.radialEscape(ws)
|
|
if esc.found:
|
|
pool = @[Cand(col: esc.col, row: esc.row, x: esc.x, y: esc.y,
|
|
pathHeat: esc.pathHeat,
|
|
destHeat: m.lavaAt(esc.col, esc.row),
|
|
clearance: m.wallClearance(esc.x, esc.y),
|
|
along: 0.0)]
|
|
escaped = true
|
|
mode = "radial"
|
|
inc m.escapePicks
|
|
if StrafeEscape and nearWall:
|
|
m.escapeActive = true
|
|
if awX != 0.0 or awY != 0.0:
|
|
m.escapeBearing = arctan2(awY, awX) * 180.0 / PI
|
|
|
|
if pool.len == 0:
|
|
m.targetValid = false
|
|
m.lastMode = "none"
|
|
if StrafeLog:
|
|
echo fmt"[strafe] WARNING: no SAFE tile on the line and no in-arena " &
|
|
fmt"escape (cands={cands.len}); keeping the last sign"
|
|
return
|
|
|
|
# ── wall-margin bias (C): a MILD preference for a tile farther from the
|
|
# wall, only within the margin and only when one is on offer. ──
|
|
if StrafeWallBias > 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..<m.rows:
|
|
for col in 0..<m.cols:
|
|
let val = m.lava[row * m.cols + col]
|
|
if val == 0.0: continue
|
|
let t = if maxLava > 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
|