Files
SirRoboGarage/common_libs/movements/the_floor_is_lava.nim
SirStone 38fbc6ecd1 j152 (default-off): geometry shapes the tile DRAW (TR_TFIL_GEO_MODE/TAU) + the offline sweep with the diversity cost
The owner: choose the tile pool not only from the heat point but from the
geometric position too. Heat stays the hard filter; the draw over the
survivors is re-weighted by turn and/or distance. Three weighting forms
(soft softmax / top-K third / rejection band), one env name carrying both
axes. Default = off = today's uniform draw, byte-for-byte (golden parity).

WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak
weight among the non-empty safe set only. This runs on the WHOLE pool the
draw already runs on, including the 2 promoted least-hot tiles the ~65%
forced picks choose from.

OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at
tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with
no diversity collapse (distinct tiles 220 -> 219, normalised entropy
0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate
in the FORCED population — does not move in ANY arm: that pool is 2 tiles
ranked by heat alone and the only lever is a coin flip.

Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in
env_report + knownEnvNames + .env.example (test_env_report was red) and
documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so
the effective steps are 10/15/20 and 10-14 admits zero extra tiles.

No battle, no server, no A/B run.
2026-09-27 10:22:24 +02:00

1623 lines
75 KiB
Nim
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## TFIL — The Floor Is Lava. Built incrementally.
import std/math
import std/random
import std/os
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
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
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)
## j146: the bullet's OWN heat was a Nim `const`, so no env arm could move it
## and 10.0 is exactly `PathDangerThreshold` — a bullet is never dangerous on
## its own and the corridor carries the whole field. Same env-overridable-var
## pattern as `CorridorHeat` below; the DEFAULTS are today's `const`s, so the
## default field is bit-identical.
## TR_TFIL_BULLET_CORE default 10.0 lava per bullet-overlapping tile
## TR_TFIL_BULLET_AURA default 5.0 lava for aura ring tiles
const
DefaultBulletCore = 10.0
DefaultBulletAura = 5.0
var
BulletCore* = DefaultBulletCore
BulletAura* = DefaultBulletAura
const EnemyCoreRadius = 18.0 ## half of 36px body
const EnemyAuraRadius = 54.0 ## 18 + 36
const EnemyCore = 40.0 ## lava per tile overlapping enemy body circle
const EnemyAura = 10.0 ## lava per tile in enemy aura ring
## The shipped heat SHAPE, env-overridable since j119 (the same pattern j106
## used for the virtual pillar). The DEFAULTS below are the shipped values, so
## the default path is unchanged; the vars are read once at module init by
## `loadTfilHeatShapeEnv`. This is what makes the heat axis sweepable on ONE
## frozen binary (before this it was a Nim `const`, so no env arm could move it).
## TR_TFIL_CORRIDOR_HEAT default 20.0 lava per corridor-overlapping tile
## TR_TFIL_WALL_HOTNESS default 30.0 peak wall radiance
## TR_TFIL_WALL_RADIANCE default 10.0 wall radiance falloff
const
DefaultCorridorHeat = 20.0
DefaultWallHotness = 30.0
DefaultWallRadiance = 10.0
var
CorridorHeat* = DefaultCorridorHeat
WallHotness* = DefaultWallHotness
WallRadiance* = DefaultWallRadiance
## The "virtual centre pillar": heat OUR code paints on the arena centre even
## though the arena has NO physical pillar there. The shipped default is now
## OFF (0/0) — the bot must not avoid open centre floor for no reason. Set
## `TR_TFIL_PILLAR_ON=1` to restore the old 30/10 field for A/B (see
## `loadTfilPillarEnv`). The ring variant already ships 0/0.
const
PillarHotnessOn = 30.0
PillarRadianceOn = 10.0
var
PillarHotness* = 0.0
PillarRadiance* = 0.0
const CommitTicks = 15 ## ticks to commit to a dodge point
const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed
## j154: the DERIVED hold budget's panic horizon. Server `rules/math.kt`:
## calcGunHeat(p) = 1 + p/5, coolDown 0.1/tick, and a gun may only fire at
## heat == 0 (`core/GunEngine.kt:36`), so two MAX-power shots are 1.6/0.1 = 16
## ticks apart and a 3.0-power bullet is `calcBulletDamage(3) = 16`. 16 ticks
## is therefore the window in which the enemy can land AT MOST its next two
## shots (32 damage) on us — the whole exposure a hold can possibly buy. It is
## also the FIRST window that admits the enemy's second shot at all, so a
## hold shorter than this can never be surprised by a third bullet.
const HoldPanicTicks = 16.0
const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras
const CoolestLevels = 2 ## how many distinct lava values count as "cool"
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
# ── Commit-behaviour knobs (A/B arms) ────────────────────────────────────────
#
# The shipped mover cancels its movement commitment whenever OUR tile changes
# (`ttrSelf`). With GridSize = 36 and speed up to 8 px/tick the bot crosses a
# tile boundary every ~5 ticks, so the 15-tick commitment is cancelled by the
# very motion it commands. `ttrOff` honours the commitment (the danger replan
# stays the safety valve); `ttrEnemy` keys the cancel to the TARGET's tile
# displacement, which is what the original comment claimed to do.
#
# Every default below reproduces the shipped mover byte-for-byte; see the
# default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`.
const DefaultDangerThreshold = 10.0 ## today's `PathDangerThreshold`
type
TfilTileReplan* = enum
ttrSelf, ttrOff, ttrEnemy
TfilReplanReason* = enum
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin
type
TfilGeoDim* = enum ## WHAT geometry the draw is shaped by
gdoOff = "off", gdoTurn = "turn", gdoDist = "dist", gdoBoth = "both"
TfilGeoShape* = enum ## HOW the shape is turned into a draw
gfSoft = "soft", gfTopK = "topk", gfRej = "rej"
proc parseGeo*(s: string): tuple[dim: TfilGeoDim, form: TfilGeoShape] =
## `"turn"`, `"dist"`, `"both"` | any of those + `"-soft"` (default) |
## `"-topk"` | `"-rej"`. Anything unrecognised, and `"off"`, is OFF = today's
## uniform draw. One env name, two axes: a `both-soft`/`both-topk` grid would
## need three names for the same three dials.
var a = s.strip().toLowerAscii()
var form = gfSoft
let dash = a.rfind('-')
if dash > 0:
case a[dash + 1 .. ^1]
of "topk": form = gfTopK; a = a[0 ..< dash]
of "rej", "rejection": form = gfRej; a = a[0 ..< dash]
of "soft": a = a[0 ..< dash] # the default form, spelled out
else: discard
result.form = form
result.dim = case a
of "turn": gdoTurn
of "dist", "distance": gdoDist
of "both": gdoBoth
else: gdoOff
proc tileReplanName*(m: TfilTileReplan): string =
case m
of ttrSelf: "self"
of ttrOff: "off"
of ttrEnemy: "enemy"
proc reasonName*(r: TfilReplanReason): string =
case r
of rrNone: "none"
of rrInit: "init"
of rrTileSelf: "tile_self"
of rrTileEnemy: "tile_enemy"
of rrDanger: "danger"
of rrExpiry: "expiry"
of rrArrival: "arrival"
of rrHyst: "hyst"
const
DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length
NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C)
ArriveRadius = 18.0 ## "we are on the committed tile" — same
## 18px radius the steering already uses
## at the bottom of this file
HullTicks = 50 ## the reachable-hull planning horizon. Past
## it the committed target is no longer
## guaranteed reachable, so the arrival
## commitment must release (stall escape).
var
TfilTileReplanMode*: TfilTileReplan = ttrSelf
TfilCommitTicks*: int = DefaultTfilCommitTicks
TfilNoRev*: bool = false
TfilCommitLogPath*: string = ""
## j144 — the commitment is held until the tile is REACHED, not for a fixed
## number of ticks. Every knob below defaults to OFF, so the shipped default
## path is byte-for-byte unchanged (see `test_tfil_commit_env.nim`).
## TR_TFIL_COMMIT_ARRIVAL 0/1 hold the committed tile until we are ON it
## (0 = shipped: fixed 15-tick dwell)
## TR_TFIL_COMMIT_MARGIN float leave it only if the best alternative tile
## is at least this much cooler (0 = no margin)
## TR_TFIL_NOREV_SPEED float while |speed| is below this, a mid-flight
## switch to the OPPOSITE side is refused
## (0 = off, today's behaviour)
TfilCommitArrival*: bool = false
TfilCommitMargin*: float = 0.0
TfilNoRevSpeed*: float = 0.0
## j145 — the turn-cost TIEBREAK, applied ONLY among tiles that already passed
## the hard heat filter. It is a WEIGHT on the draw, never a term in the heat
## score (see `turnWeights`).
## TR_TFIL_TURN_BIAS float the turn TIEBREAK's odds ratio: a
## straight-ahead safe tile is drawn
## `1 + bias` times as often as a 180 deg one
## (0 = off, today's uniform draw exactly)
## TR_TFIL_TURN_REF_DEG float turn below which there is no penalty (deg)
TfilTurnBias*: float = 0.0
TfilTurnRefDeg*: float = 45.0
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
## Off = the shipped `prev - energy` detector byte-for-byte.
TfilFireFix*: bool = true
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
## detected enemy fire, for the ghost-vs-observer position probe. Observability
## only; off by default.
TfilFireDiag*: bool = false
## j150: the picker's hard heat cutoff was a proc-local `const`, so no offline
## sweep could move it. Same env-overridable-var pattern as the shape knobs;
## the DEFAULT is today's `10.0`, so the default path is bit-identical.
## TR_TFIL_DANGER_THRESHOLD default 10.0
TfilDangerThreshold* = DefaultDangerThreshold
## j152: `TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` — GEOMETRY shapes the DRAW.
## Heat still gates the pool with the same hard filter; this only re-weights
## the survivors by how far the tile sits from where we are already going.
## TR_TFIL_GEO_MODE off | turn | dist | both [+ `-soft` | `-topk` | `-rej`]
## TR_TFIL_GEO_TAU deg, 0 = off (= today's uniform draw, exactly)
## WHAT IS DIFFERENT FROM j9 (`TR_TFIL_TURN_BIAS`, a live null): that was a
## tiebreak WEIGHT applied only among the non-empty safe set. This runs on the
## WHOLE pool the draw already runs on, so it also shapes the 2 promoted
## least-hot tiles the ~65% forced (safePre < 2) picks choose from.
TfilGeoMode*: TfilGeoDim = gdoOff
TfilGeoForm*: TfilGeoShape = gfSoft
TfilGeoTau*: float = 0.0
## j151: `TR_TFIL_ARRIVE_TICKS` — refuse a candidate we cannot REACH inside
## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a
## preference; empty pool => today's full pool, so it can never starve the
## draw. 0 (default) = off = byte-for-byte today.
TfilArriveTicks*: float = 0.0
## j153: `TR_TFIL_HOLD_WHEN_TRAPPED` — when the SAFE set is EMPTY (zero tiles
## with `pathMaxHeat <= PathDangerThreshold` inside the reachable hull), STOP
## for this tick instead of promoting the 2 least-hot blocked tiles. The
## owner's rule: "if no tile is found to go, to not choose the less dangerous,
## but to stay still! the next tick probably the situation already changed".
## ONE tick only, never latched: the hold is taken at the pick site, and the
## pick site only runs when `commitTicks == 0`, so the very next tick
## re-evaluates the field from scratch. That is why there is no max-hold knob:
## a counter can only add a way to get stuck.
## Default false = byte-for-byte today's promote-the-2 behaviour.
TfilHoldWhenTrapped*: bool = false
## j154: `TR_TFIL_HOLD_MAX_TICKS` — the BOUNDED version of the j153 one-tick
## hold. While the safe tile set stays EMPTY the mover holds position for at
## most this many ticks (the enemy's own rate of fire bounds what waiting can
## buy: see `HoldPanicTicks`). Rules, all four of them load-bearing:
## * a safe tile exists -> release on the SAME tick, always
## * a tracked bullet reaches us within `min(N, 16)` ticks -> PANIC RELEASE,
## the hold is overridden and the normal promote-the-2 fallback resumes
## * the counter resets when a safe tile is taken, so the bound is per
## empty-streak, not per round
## * the gun is untouched: `computeMove` never fires, so a held tick still
## fires exactly as every other tick (verified in `test_tfil_commit_env`)
## Default 0 = OFF = byte-for-byte today's behaviour, j153 included.
TfilHoldMaxTicks*: int = 0
## j150: `TR_TFIL_DIAG` — fill `TfilLoss*` with the per-pick LOSS HISTOGRAM
## (how many tiles die at each picker stage). Pure counters, off by default.
TfilDiag*: bool = false
## The picker's loss histogram, one entry per PICK. Stage sizes, in picker
## order: tiles inside the reachable hull -> survivors of the `CoolestLevels`
## distinct-lava-value filter -> survivors of the `pathMaxHeat <= threshold`
## filter (counted BEFORE the "keep 2 anyway" promotion) -> what the draw
## actually runs on. Pure bookkeeping, read by the offline ruler.
type TfilLossStats* = object
picks*: int
sReach*: int ## inside the reachable hull
sCool*: int ## after CoolestLevels (= 2) distinct-value filter
sSafe*: int ## after the path heat filter, pre-promotion
sCand*: int ## what the draw ran on (post blocked-tile/no-rev)
emptySafe*: int ## picks that had to break the heat filter (sSafe < 2)
safeHist*: array[8, int] ## sSafe size buckets: 0,1,2-3,4-7,8-15,16-31,32-63,64+
rejectedHeat*: seq[float] ## pathMaxHeat of every tile the filter dropped
admittedHeat*: seq[float] ## pathMaxHeat of every tile that passed it
chosenHeat*: seq[float]
var TfilLoss*: TfilLossStats
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"]
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 loadTfilCommitEnv*() =
## Read the commit knobs. Called once at module init; the guard test calls it
## again after `putEnv` so the non-default arms can be exercised in one process.
case getEnv("TR_TFIL_TILE_REPLAN", "self").strip().toLowerAscii()
of "off", "none", "never", "0", "false": TfilTileReplanMode = ttrOff
of "enemy", "target": TfilTileReplanMode = ttrEnemy
else: TfilTileReplanMode = ttrSelf
TfilCommitTicks = max(1, getEnvInt("TR_TFIL_COMMIT_TICKS", DefaultTfilCommitTicks))
TfilNoRev = getEnvBool("TR_TFIL_NO_REV", false)
TfilCommitLogPath = getEnv("TR_TFIL_COMMIT_LOG", "")
TfilCommitArrival = getEnvBool("TR_TFIL_COMMIT_ARRIVAL", false)
TfilCommitMargin = max(0.0, getEnvFloat("TR_TFIL_COMMIT_MARGIN", 0.0))
TfilNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_NOREV_SPEED", 0.0))
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD",
DefaultDangerThreshold))
TfilDiag = getEnvBool("TR_TFIL_DIAG", false)
let (gd, gf) = parseGeo(getEnv("TR_TFIL_GEO_MODE", "off"))
TfilGeoMode = gd
TfilGeoForm = gf
TfilGeoTau = max(0.0, getEnvFloat("TR_TFIL_GEO_TAU", 0.0))
# j151: hard arrival bound. The draw is UNIFORM over every safe tile inside the
# 50-tick reachable hull, so a tile 47 ticks away had the same 1-in-52 chance
# as the adjacent one, while the target is only HELD for CommitTicks=15. The
# offline ruler (measure_tfil_pick_defects) measured 65% of picks beyond the
# 15-tick horizon and a 6.5% arrival rate. 0 = off = today's uniform draw.
TfilArriveTicks = max(0.0, getEnvFloat("TR_TFIL_ARRIVE_TICKS", 0.0))
TfilHoldWhenTrapped = getEnvBool("TR_TFIL_HOLD_WHEN_TRAPPED", false)
TfilHoldMaxTicks = max(0, getEnvInt("TR_TFIL_HOLD_MAX_TICKS", 0))
if not TfilDiag: TfilLoss = TfilLossStats()
loadTfilCommitEnv()
proc loadTfilPillarEnv*() =
## Read the virtual-centre-pillar knob. OFF by default: the shipped field is
## the arena with no invented centre hazard. `TR_TFIL_PILLAR_ON=1` restores
## the pre-change 30/10 field so the two defaults can be A/B-ed offline.
if getEnvBool("TR_TFIL_PILLAR_ON", false):
PillarHotness = PillarHotnessOn
PillarRadiance = PillarRadianceOn
else:
PillarHotness = 0.0
PillarRadiance = 0.0
loadTfilPillarEnv()
proc loadTfilHeatShapeEnv*() =
## Read the shipped-mover heat-shape overrides. Called once at module init;
## callable again after `putEnv` so one process can A/B the fields. The
## defaults reproduce the shipped `const`s exactly, so the default field is
## bit-identical. The `TR_TFIL_CORRIDOR_HEAT` / `TR_TFIL_WALL_HOTNESS` names
## are shared with the ring mover (which reads its own copies with its own
## retuned defaults); `TR_TFIL_WALL_RADIANCE` is new here.
CorridorHeat = max(0.0, getEnvFloat("TR_TFIL_CORRIDOR_HEAT", DefaultCorridorHeat))
WallHotness = max(0.0, getEnvFloat("TR_TFIL_WALL_HOTNESS", DefaultWallHotness))
WallRadiance = max(0.0, getEnvFloat("TR_TFIL_WALL_RADIANCE", DefaultWallRadiance))
BulletCore = max(0.0, getEnvFloat("TR_TFIL_BULLET_CORE", DefaultBulletCore))
BulletAura = max(0.0, getEnvFloat("TR_TFIL_BULLET_AURA", DefaultBulletAura))
loadTfilHeatShapeEnv()
# ── Time-indexed bullet heat (TR_TFIL_HEAT_TIME=1, default OFF = shipped) ─────
#
# WHY: the flat model gives every bullet-overlapping tile the same heat and
# paints the bullet's corridor all the way to the arena wall, regardless of how
# far away or how weak the bullet still is. `CorridorHeat` (20) is twice
# `PathDangerThreshold` (10), so ONE weak far bullet saturates a 108px-wide
# swath from its nose to the wall, and a path the bullet will not reach until
# long after the bot has left it is already marked unsafe.
#
# WHAT: heat becomes a function of `dt`, the time (ticks) until the bullet
# REACHES that cell:
#
# dt = along / speed # along = distance from the bullet
# heat = magnitude(power) * decay(dt)
#
# * `decay(dt) = exp(-dt / tau)` is a function of TIME, not pixels. A fixed time
# constant `tau` therefore projects a PIXEL reach of `speed * tau`: a fast
# bullet's slope is longer, a slow one's shorter — DERIVED from the physics
# (`speed = 20 - 3*power`), not hand-tuned per power. `tau` = TR_TFIL_HEAT_TAU.
# * `magnitude(power)` scales the near-end heat with power from DAMAGE, not from
# hit chance: server damage is `calcBulletDamage = 4p`, linear in p, and
# `SCORE_PER_BULLET_DAMAGE = 1.0`, so a stronger bullet costs more when it
# hits. Hit probability is FLAT across power (docs/env_reference.md), so risk
# does NOT justify power scaling — the COST of the hit does. Floored at 1.0 so
# a weak bullet's near end is never LESS dangerous than the flat model.
# Gain = TR_TFIL_HEAT_POWER_GAIN.
#
# TIME-INDEXED PLANNER (NOT implemented, by design): because every source is
# already expressed as `f(dt)`, evaluating a cell at the tick the bot would
# ARRIVE there is the one-line change `heatDecay(dt - arrivalDelay)` — heat a
# later bullet's path by that bullet's lead on the bot's own arrival time, so
# the bot can use the path and leave before the bullet arrives. This is the real
# fix for the user's second point; the shipped move is unchanged until then.
var
TfilHeatTime*: bool = false
TfilHeatTau*: float = 9.0 ## decay time constant, ticks
TfilHeatPowerGain*: float = 1.0 ## extra near-end heat at max power (damage proxy)
proc loadTfilHeatEnv*() =
## Read the heat-model knobs. Called once at module init; also callable after
## `putEnv` so one process can A/B both models (the offline ruler does this).
TfilHeatTime = getEnvBool("TR_TFIL_HEAT_TIME", false)
TfilHeatTau = max(0.05, getEnvFloat("TR_TFIL_HEAT_TAU", 9.0))
TfilHeatPowerGain = max(0.0, getEnvFloat("TR_TFIL_HEAT_POWER_GAIN", 1.0))
loadTfilHeatEnv()
# ── Corridor LENGTH bound (TR_TFIL_CORRIDOR_TICKS, default 0 = to the wall) ──
#
# WHY: the shipped corridor is the rotated rectangle from the bullet to the
# ARENA WALL, so one distant bullet blankets a 40px-wide swath across the whole
# map. That is not physical: in `t` ticks a bullet covers `speed * t` px, and
# `speed = 20 - 3*power`, so a fast (low-power) bullet's reach is LONG and a
# slow one's is SHORT.
#
# WHAT: bound the corridor's LENGTH, nothing else — the heat inside the
# surviving corridor is EXACTLY today's (`heatDecay(along/speed)` is unchanged),
# so unlike the j119 time-indexed heat model this does NOT decay heat along the
# corridor; it only removes corridor that no bullet will reach.
var TfilCorridorTicks* = 0.0 ## 0 (default) = to the wall: byte-for-byte shipped
proc loadTfilCorridorEnv*() =
TfilCorridorTicks = getEnvFloat("TR_TFIL_CORRIDOR_TICKS", 0.0)
loadTfilCorridorEnv()
proc corridorReach*(tWall, speed: float): float =
## The ONE place the corridor length is decided, so the heat field and the
## drawn outline can never disagree. `tWall` = distance to the wall along the
## heading, `speed` = the ghost's own px/tick. 0 = to the wall (shipped).
if TfilCorridorTicks <= 0.0: tWall else: min(tWall, speed * TfilCorridorTicks)
proc heatDecay*(dt: float): float =
## Fraction of a bullet's heat still present `dt` ticks before it arrives.
## Exactly 1.0 when the time model is off, so the default field is
## bit-identical to the flat model (multiplying any heat by 1.0 is exact).
if not TfilHeatTime or dt <= 0.0: return 1.0
exp(-dt / TfilHeatTau)
proc bulletMagScale*(power: float): float =
## Near-end heat multiplier from the bullet's DAMAGE (4p, linear in power),
## floored at 1.0. Exactly 1.0 when the time model is off.
if not TfilHeatTime: return 1.0
1.0 + TfilHeatPowerGain * (power / 3.0)
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)
type
TrackedBullet = object
originX, originY: float
x, y: float
velX, velY: float ## speed * cos(heading), speed * sin(heading)
power: float
alive: bool
age: int ## ticks alive; die if > 200
TFILModule* = object
debugGraphics*: bool
cols, rows: int
marginX, marginY: float
arenaWidth, arenaHeight: float
lava: seq[float] # flat row-major, index = row*cols + col
bullets: seq[TrackedBullet]
fire: FireTracker ## shared energy-drop detector (j134)
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
commitTicks: int ## ticks remaining on commitment
commitAge: int ## total ticks held on the current target
commitLava: float ## lava at commit time (for spike detection)
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
cachedHull: seq[tuple[x, y: float]]
cachedInsideTiles: seq[tuple[col, row: int]]
callCount: int ## computeMove call count; 0 = never called
lastBotX, lastBotY: float ## bot position at last call; used to detect position jumps
lastTileCol, lastTileRow: int ## grid tile at last call; used to detect gradual displacement
lastEnemyTileCol, lastEnemyTileRow: int ## grid tile of the target at last call (arm D)
replanReason: TfilReplanReason ## why the last commitment ended (log only)
lastPickCall: int ## callCount at the last pick (log only)
picks: int ## number of picks this round (log only)
lastPickPromoted: bool ## the last pick had to break the hard
## heat filter (fewer than 2 safe tiles)
lastPickMinTurn: float ## smallest |turn| available in the
## candidate set of the last pick (j145:
## lets a caller measure the REGRET of the
## draw instead of only the drawn value)
lastHeld: bool ## the last tick HELD position
## (j153: TR_TFIL_HOLD_WHEN_TRAPPED,
## the safe set was empty)
holdTicks: int ## j154: ticks HELD in the current
## empty-safe-set streak; reset to 0
## when a safe tile is taken
lastPickSafe: int ## how many SAFE tiles (pathMaxHeat
## <= PathDangerThreshold) the last pick
## drew from (j146: the size of the set
## the heat SHAPE leaves available)
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false, fire: initFireTracker())
proc removeBulletNear*(m: var TFILModule, x, y: float) =
## Mark the tracked bullet closest to (x,y) within GridSize tolerance as dead.
var bestIdx = -1
var bestD2 = GridSize * GridSize # tolerance²
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: TFILModule, id: int): float = m.fire.prevEnergyGet(id)
proc prevEnergySet(m: var TFILModule, id: int, energy: float) =
m.fire.prevEnergySet(id, energy)
proc clearGraphics*(m: var TFILModule) =
## No-op: the SVG buffer is a module-level global cleared by the framework
## after every go(). Exists so callers can signal "TFIL is inactive this tick".
discard
proc resetRound*(m: var TFILModule) =
m.bullets = @[]
m.fire.reset()
m.commitTicks = 0
m.commitAge = 0
m.holdTicks = 0 ## j154: the bounded hold never survives a round
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.blockedTile = (col: 0, row: 0, active: false)
m.callCount = 0
m.lastBotX = 0.0
m.lastBotY = 0.0
m.lastTileCol = 0
m.lastTileRow = 0
m.lastEnemyTileCol = -1
m.lastEnemyTileRow = -1
m.replanReason = rrNone
m.lastPickCall = 0
m.picks = 0
m.lastPickPromoted = false
m.lastPickMinTurn = 0.0
m.lastHeld = false
m.lastPickSafe = 0
# ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ──────────────────
# One JSONL line per computeMove call, used by the A/B to prove the treatment
# actually bit (decision interval, replan reason, reversal rate, speed).
var
tfilLogFile: File
tfilLogOpen = false
tfilLogPathOpen = ""
proc closeTfilCommitLog*() =
## Close the diagnostics stream. Needed because the log path is a knob: a
## caller (the A/B, or the guard test) may point it somewhere else mid-process.
if tfilLogOpen:
try: tfilLogFile.close()
except CatchableError: discard
tfilLogOpen = false
tfilLogPathOpen = ""
proc tfilLogWrite(line: string) =
if TfilCommitLogPath.len == 0: return
if tfilLogOpen and tfilLogPathOpen != TfilCommitLogPath: closeTfilCommitLog()
if not tfilLogOpen:
try:
tfilLogFile = open(TfilCommitLogPath, fmAppend)
tfilLogOpen = true
tfilLogPathOpen = TfilCommitLogPath
except CatchableError:
return
try:
tfilLogFile.writeLine(line)
tfilLogFile.flushFile()
except CatchableError:
discard
proc initGrid(m: var TFILModule, 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) # all 0.0
proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave for a confirmed fire of `power`: linear prediction
## of our position at arrival becomes the aim heading. (Was inlined in
## `detectFires`; extracted so the shared split can spawn several.)
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 * PI / 180.0) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * PI / 180.0) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
let vx = speed * cos(heading)
let vy = speed * sin(heading)
var gx = ei.x
var gy = ei.y
if FireLag > 0:
gx += vx * FireLag.float
gy += vy * FireLag.float
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: gx, y: gy,
velX: vx,
velY: vy,
power: power,
alive: true,
age: 0)
proc noteEnemyBulletHit*(m: var TFILModule, power: float) =
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
if TfilFireFix:
m.fire.noteEnemyBulletHit(power)
proc noteDamageDealt*(m: var TFILModule, damage: float) =
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
if TfilFireFix:
m.fire.noteDamageDealt(damage)
proc detectFires(m: var TFILModule, ws: WorldState) =
## Check all enemies for energy drops; spawn a tracked bullet per confirmed
## fire. The shared tracker corrects the delta and splits over-cap drops.
for ei in ws.enemies:
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
m.spawnTrackedWave(ws, ei, p)
if TfilFireDiag 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 bulletPanic*(m: TFILModule, selfX, selfY, horizon: float): bool =
## PANIC RELEASE (j154). True when a TRACKED bullet's straight path comes
## within its own CORE of where WE are at any time in `[0, horizon]` ticks.
## Closest approach of a straight ray is `t* = ((self - b) . v) / |v|^2`; the
## `t* < 0` case is the `dot < 0` reap in `advanceBullets` (it is already past
## us) and `t* > horizon` is "not inside the window". The prediction is the
## tracked ghost's OWN position/velocity — the same model `pathMaxHeat` decays
## by and `advanceBullets` integrates — so there is no second arrival model in
## this file. Exported so the guard test can call it directly.
if horizon <= 0.0: return false
for b in m.bullets:
let v2 = b.velX * b.velX + b.velY * b.velY
if v2 < 1e-9: continue
let dx = selfX - b.x
let dy = selfY - b.y
let t = (dx * b.velX + dy * b.velY) / v2
if t < 0.0 or t > horizon: continue
let mx = dx - t * b.velX
let my = dy - t * b.velY
if sqrt(mx * mx + my * my) <= bulletRadii(b.power).core: return true
false
proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
var i = 0
while i < m.bullets.len:
var b = m.bullets[i]
b.x += b.velX
b.y += b.velY
b.age += 1
# Death conditions (any triggers removal):
# 1. Passed us (dot < 0, moving away)
# 2. Out of arena bounds
# 3. Too old (>200 ticks)
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)
type CorridorGeom = object
dx, dy: float ## unit heading
px, py: float ## unit perpendicular
tMin: float ## distance to wall
bx, by: float ## bullet origin
proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom =
## `tMin` is the corridor LENGTH: the distance to the wall, bounded by
## `corridorReach` (TR_TFIL_CORRIDOR_TICKS). Unset (0) -> exactly the wall
## distance, so the shipped field and outline are byte-for-byte unchanged.
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
if speed < 0.001: return
let dx = b.velX / speed
let dy = b.velY / speed
var tMin = Inf
if dx > 0.0: tMin = min(tMin, (arenaWidth - b.x) / dx)
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy)
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
tMin = corridorReach(tMin, speed)
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
proc lavaAt(m: TFILModule, col, row: int): float =
m.lava[row * m.cols + col]
proc tileAt(m: TFILModule, 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 pointInHull(px, py: float, hull: seq[(float, float)]): bool =
var inside = false
var j = hull.high
for i in 0..hull.high:
if ((hull[i][1] > py) != (hull[j][1] > py)) and
(px < (hull[j][0] - hull[i][0]) * (py - hull[i][1]) / (hull[j][1] - hull[i][1]) + hull[i][0]):
inside = not inside
j = i
inside
proc computeReachableHull(x0, y0, heading0, speed0,
arenaW, arenaH: float, ticks: int = 50): seq[(float, float)] =
## Simulate `ticks` ticks at various turn rates / target speeds.
## Returns convex hull (gift-wrap) of final positions.
const TargetSpeeds = [8.0, 4.0, -4.0, -8.0]
const NumRates = 11
var pts: seq[(float, float)]
pts.add (x0, y0) # always reachable: stay
for tSpeed in TargetSpeeds:
# Max turn rate at target speed (approximate; actual varies per tick but close enough)
let mtr = 10.0 - 0.75 * abs(tSpeed)
for ri in 0..<NumRates:
let turnRate = if NumRates == 1: 0.0
else: -mtr + (2.0 * mtr / (NumRates - 1).float) * ri.float
var x = x0; var y = y0
var h = heading0; var spd = speed0
for _ in 0..<ticks:
# Accelerate toward target
if spd < tSpeed: spd = min(spd + 1.0, tSpeed)
elif spd > tSpeed: spd = max(spd - 1.0, tSpeed)
spd = clamp(spd, -MaxSpeed, MaxSpeed)
# Turn (clamp to current max turn rate)
let curMtr = 10.0 - 0.75 * abs(spd)
let tr = clamp(turnRate, -curMtr, curMtr)
h += tr
let hr = h * PI / 180.0
x = clamp(x + spd * cos(hr), 0.0, arenaW)
y = clamp(y + spd * sin(hr), 0.0, arenaH)
pts.add (x, y)
# Gift-wrap convex hull (O(n²), ~45 points — fine)
# Find leftmost point as start
var startIdx = 0
for i in 1..<pts.len:
if pts[i][0] < pts[startIdx][0] or
(pts[i][0] == pts[startIdx][0] and pts[i][1] < pts[startIdx][1]):
startIdx = i
var hull: seq[(float, float)]
var cur = startIdx
while true:
hull.add pts[cur]
var next = 0
for i in 1..<pts.len:
if next == cur:
next = i
continue
let ax = pts[next][0] - pts[cur][0]
let ay = pts[next][1] - pts[cur][1]
let bx = pts[i][0] - pts[cur][0]
let by = pts[i][1] - pts[cur][1]
let cross = ax * by - ay * bx
if cross < 0.0: # i is more counterclockwise
next = i
cur = next
if cur == startIdx: break
hull
proc noRevWeights*(dirs: openArray[float], travelDeg: float): seq[int] =
## Soft no-reversal preference (arm C). `dirs` are candidate directions in
## degrees (world frame). Forward candidates (<=90 deg off the travel
## direction) get `NoRevForwardWeight`, every other candidate gets 1.
## A weight is NEVER 0, so the preference can only down-weight, never filter:
## the candidate pool can never be emptied by it. When no forward candidate
## exists the caller falls back to a uniform draw (all weights 1 anyway).
for d in dirs:
var a = d - travelDeg
while a > 180.0: a -= 360.0
while a < -180.0: a += 360.0
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1)
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
## j144: which candidate tiles may a slow, mid-flight switch take?
## `offs` are the signed angles (deg) from the travel direction to each
## candidate, `threshold` is the speed gate (px/tick). Returns the indices
## that are NOT more than 90 deg off — i.e. the bot does not have to turn
## around to reach them.
## IF EVERY candidate is behind us the reversal is unavoidable (boxed in, or
## the field only offers rearward space), so the single LEAST-bad one is
## returned: a shallow turn, not a 180 deg flip. The result is NEVER empty,
## so the pick can never be starved. `threshold <= 0` means the knob is off
## and every candidate stays.
if offs.len == 0: return
if threshold <= 0.0: # the knob is off: no filtering at all
for i in 0..<offs.len: result.add i
return
var keep: seq[int]
for i, a in offs:
if abs(a) <= 90.0: keep.add i
if keep.len > 0: return keep
var best = 0
for i, a in offs:
if abs(a) < abs(offs[best]): best = i
@[best]
proc turnWeights*(turns: openArray[float], bias, refDeg: float): seq[int] =
## j145: the turn-cost TIEBREAK, as an integer draw weight per candidate:
##
## w = max(1, round(1 + bias * (1 - max(0, |turn| - refDeg) / 180)))
##
## i.e. a straight-ahead (within `refDeg`) safe tile is drawn `1 + bias` times
## as often as a 180 deg one, falling LINEARLY in between. Read `bias` as that
## odds ratio: bias 0 = today's uniform draw, bias 9 = 10:1.
## Only tiles that already passed the hard heat filter reach this function, so
## it can never rescue a hot one.
## WHY A WEIGHT AND NOT `heat + k*turnDeg`: mixing the two trades dodging for
## smoothness, which is backwards in a bullet-dodging game — the safety filter
## stays hard and the turn only re-orders the survivors.
## WHY NOT AN ARGMIN: job j51 (3142b70) measured that randomness in this tie
## is LOAD-BEARING for this bot — a deterministic argmin scored worse. So the
## draw stays random and only its TILT is new.
## The weight is floored at 1, so the pool can never be starved.
for t in turns:
result.add max(1, int(round(1.0 + bias *
(1.0 - max(0.0, t - refDeg) / 180.0))))
# ── j152: the geometric DRAW ────────────────────────────────────────────────
# The owner: "choose tiles pool not only from the heat point but from a
# geometrically position too". Heat is already a HARD filter (unchanged); this is
# the second half — the distribution the draw samples from.
#
# WHY THIS IS NOT j9 AGAIN. j9 (`TR_TFIL_TURN_BIAS`) down-weighted the turn among
# the non-empty safe set and measured a live null. This runs on the pool the draw
# ALREADY runs on, which for ~65% of picks is the 2 promoted least-hot tiles that
# broke the heat filter — j9 could not see those at all.
const GeoDegPerTick = 12.0 ## distance cost, in "effective degrees": a 15-tick
## trip (the commitment horizon) costs the same as a
## 180 deg turn, so ONE tau knob means the same
## thing in `turn` and `dist` mode.
proc geoCosts*(turns, ttas: openArray[float], dim: TfilGeoDim): seq[float] =
## Per-candidate cost in effective degrees. Never filters: it only re-orders
## and re-weights tiles that already passed the heat filter.
for i in 0..<turns.len:
result.add (if dim in {gdoTurn, gdoBoth}: turns[i] else: 0.0) +
(if dim in {gdoDist, gdoBoth}: ttas[i] * GeoDegPerTick else: 0.0)
proc geoPick*(turns, ttas: openArray[float], dim: TfilGeoDim, form: TfilGeoShape,
tau: float): int =
## Draw index from `candidates` under the geometric weight. NEVER returns -1
## and NEVER returns an out-of-range index, so no arm can starve the pick.
let c = geoCosts(turns, ttas, dim)
var best = 0
for i in 1..<c.len:
if c[i] < c[best]: best = i
case form
of gfRej:
# Rejection sampling: a GEOMETRY-FREE acceptance test (no shape function at
# all) — uniform draw, redraw while the candidate costs more than `tau`.
# ponytail: 16 tries is a fixed budget; widen it if the band ever tightens
# enough that the fallback below starts dominating.
for _ in 0..<16:
let i = rand(c.high)
if c[i] <= tau: return i
return best # band too tight: take the best available, never starve
of gfTopK:
# Hard: keep the best THIRD, uniform inside. Collapses diversity by design —
# measured against the soft form before it could ever be a default.
let k = max(1, (c.len + 2) div 3)
var pool: seq[int]
var taken = newSeq[bool](c.len)
for _ in 0..<k:
var b = -1
for i in 0..<c.len:
if not taken[i] and (b < 0 or c[i] < c[b]): b = i
taken[b] = true
pool.add b
return pool[rand(pool.high)]
of gfSoft:
# w = exp(-cost / tau), NORMALISED so the best tile weighs exactly 1.0. The
# normalisation is what makes "all tiles tie" (and only that) degrade to the
# uniform draw, and makes starvation impossible.
var w: seq[float]
for x in c: w.add exp(-x / tau)
var wMax = 0.0
for x in w: wMax = max(wMax, x)
if wMax <= 0.0: return best
var total = 0.0
for x in w: total += x
let r = rand(total)
var acc = 0.0
for i, x in w:
acc += x
if r < acc: return i
return best
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
if m.cols == 0:
m.initGrid(ws.arenaWidth, ws.arenaHeight)
# Soft reset: detect gap by position jump (rammer moved us across ticks)
# 12px threshold: above single-tick max movement (8px) but catches even short ram gaps
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.commitTicks = 0 # force replan — old target invalid
m.cachedHull = @[] # stale position/heading
m.cachedInsideTiles = @[]
m.bullets = @[] # bullet positions are hopelessly stale
m.blockedTile = (col: 0, row: 0, active: false)
# Re-snapshot prevEnergy so energy changes during ramming aren't misread as fires
m.fire.prevEnergy = @[]
for ei in ws.enemies:
m.fire.prevEnergySet(ei.id, ei.energy)
# Tile-change replan — see the knob rationale at the top of the file.
# j144: with TR_TFIL_COMMIT_ARRIVAL the SELF-tile crossing is exactly the event
# that must NOT cancel a commitment: crossing a boundary is the very motion
# the commitment commands, and it fires every ~5 ticks (GridSize 36, speed 8).
# Under the shipped default (arrival off) this is the original block verbatim.
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
not (TfilCommitArrival and TfilTileReplanMode == ttrSelf):
case TfilTileReplanMode
of ttrSelf:
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.replanReason = rrTileSelf
of ttrEnemy:
# The comment's original intent: replan when the TARGET went stale, not
# when WE moved. Uses the primary enemy's tile displacement.
if ws.enemies.len > 0 and m.lastEnemyTileCol >= 0:
let ec = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
let er = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
if ec != m.lastEnemyTileCol or er != m.lastEnemyTileRow:
m.commitTicks = 0
m.cachedHull = @[]
m.cachedInsideTiles = @[]
m.replanReason = rrTileEnemy
of ttrOff:
discard # honour the commitment; the danger replan is the safety valve
# Per-tick: advance existing bullets, detect new fires
m.advanceBullets(ws.selfX, ws.selfY)
m.detectFires(ws)
# Recompute lava from scratch each tick
for i in 0..<m.lava.len: m.lava[i] = 0.0
for b in m.bullets:
let bx = b.x
let by = b.y
let (coreR, auraR) = bulletRadii(b.power)
# Time model: project the tile's nearest point onto the bullet heading. When
# the model is off these factors are exactly 1.0, so heat is unchanged.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bUx = if bSpeed > 0.0: b.velX / bSpeed else: 0.0
let bUy = if bSpeed > 0.0: b.velY / bSpeed else: 0.0
let bMag = bulletMagScale(b.power)
let colMin = max(0, int(floor((bx - auraR - m.marginX) / GridSize)))
let colMax = min(m.cols-1, int(floor((bx + auraR - m.marginX) / GridSize)))
let rowMin = max(0, int(floor((by - auraR - m.marginY) / GridSize)))
let rowMax = min(m.rows-1, int(floor((by + auraR - m.marginY) / GridSize)))
for row in rowMin..rowMax:
for col in colMin..colMax:
let x0 = m.marginX + col.float * GridSize
let y0 = m.marginY + row.float * GridSize
let nearX = clamp(bx, x0, x0 + GridSize)
let nearY = clamp(by, y0, y0 + GridSize)
let dx = nearX - bx
let dy = nearY - by
let d2 = dx*dx + dy*dy
if d2 <= coreR * coreR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletCore * bMag *
heatDecay(along / bSpeed)
elif d2 <= auraR * auraR:
let along = dx * bUx + dy * bUy
m.lava[row * m.cols + col] += BulletAura * bMag *
heatDecay(along / bSpeed)
# Corridor heat — rotated rectangle from bullet position to arena wall, auraR wide
for b in m.bullets:
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue # zero-speed bullet, skip
let (_, auraR) = bulletRadii(b.power)
# Time model: the corridor gradient is `dt = along / speed` (see the heat
# block above). Off -> exactly 1.0, so the corridor is the flat shipped one.
let bSpeed = sqrt(b.velX * b.velX + b.velY * b.velY)
let bMag = bulletMagScale(b.power)
let wx = cg.bx + cg.dx * cg.tMin
let wy = cg.by + cg.dy * cg.tMin
# Bounding box of the 4 corners
let c0x = cg.bx + cg.px * auraR; let c0y = cg.by + cg.py * auraR
let c1x = cg.bx - cg.px * auraR; let c1y = cg.by - cg.py * auraR
let c2x = wx - cg.px * auraR; let c2y = wy - cg.py * auraR
let c3x = wx + cg.px * auraR; let c3y = wy + cg.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
# Project tile center onto heading and perpendicular axes
let relX = cx - cg.bx
let relY = cy - cg.by
let along = relX * cg.dx + relY * cg.dy
let perp = relX * cg.px + relY * cg.py
if along >= 0.0 and along <= cg.tMin and perp >= -auraR and perp <= auraR:
m.lava[row * m.cols + col] += CorridorHeat * bMag *
heatDecay(along / bSpeed)
# Enemy heat auras — core (18px) and aura ring (54px), same pattern as bullets
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 heat — additive with bullet heat
for row in 0..<m.rows:
for col in 0..<m.cols:
let heat = max(0.0, WallHotness - col.float * WallRadiance) +
max(0.0, WallHotness - (m.cols-1-col).float * WallRadiance) +
max(0.0, WallHotness - row.float * WallRadiance) +
max(0.0, WallHotness - (m.rows-1-row).float * WallRadiance)
m.lava[row * m.cols + col] += heat
# Pillar radiance heat — center 1×1, 1×2, 2×1, or 2×2 depending on grid parity
let pc0 = if m.cols mod 2 == 1: m.cols div 2 else: m.cols div 2 - 1
let pc1 = m.cols div 2 # same as pc0 when odd, pc0+1 when even
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)
if m.debugGraphics:
# Compute max lava for heat gradient
var maxLava = 0.0
for v in m.lava:
if v > maxLava: maxLava = v
# Non-zero tiles: colored border + colored value text (yellow→orange→red)
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)
# Draw tracked bullet circles
setStrokeColor(RED)
setStrokeWidth(1.0)
setFillColor(RED)
for b in m.bullets:
let (coreR, auraR) = bulletRadii(b.power)
drawCircle(b.x, b.y, coreR)
fillCircle(b.x, b.y, 3.0)
setStrokeColor(fromHex("#FF8800")) # orange aura
setStrokeWidth(1.0)
drawCircle(b.x, b.y, auraR)
setStrokeColor(RED)
setStrokeWidth(1.0)
# Danger corridor: rotated rectangle projecting each bullet forward to arena wall
setStrokeColor(fromHex("#AAAAAA"))
setStrokeWidth(1.0)
for b in m.bullets:
let (_, auraR) = bulletRadii(b.power)
let cg = corridorGeom(b, m.arenaWidth, m.arenaHeight)
if cg.tMin == 0.0: continue
# When the time model is on, draw only as far as the corridor still blocks
# (heat > PathDangerThreshold = 10); otherwise the outline would claim a
# wall-to-wall threat the field no longer has. Off -> reach = cg.tMin.
var reach = cg.tMin
if TfilHeatTime:
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
let near = CorridorHeat * bulletMagScale(b.power)
reach = if speed > 0.0 and near > 10.0:
min(reach, -TfilHeatTau * ln(10.0 / near) * speed)
else: 0.0
let wx = cg.bx + cg.dx * reach
let wy = cg.by + cg.dy * reach
let corners: seq[(float, float)] = @[
(cg.bx + cg.px * auraR, cg.by + cg.py * auraR),
(cg.bx - cg.px * auraR, cg.by - cg.py * auraR),
(wx - cg.px * auraR, wy - cg.py * auraR),
(wx + cg.px * auraR, wy + cg.py * auraR),
]
drawPolygon(corners)
# Enemy core (cyan) and aura (green)
setStrokeColor(fromHex("#00FFFF")) # cyan core
setStrokeWidth(1.5)
for ei in ws.enemies:
drawCircle(ei.x, ei.y, EnemyCoreRadius)
setStrokeColor(fromHex("#00CC00")) # green aura
setStrokeWidth(1.0)
for ei in ws.enemies:
drawCircle(ei.x, ei.y, EnemyAuraRadius)
# ── Tile-based Dodge System ───────────────────────────────────────────────────
let botCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
let botRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
# log-only bookkeeping for this tick
var pickedThisTick = false
var pickedRev = false
var pickedMidFlight = false
var pickedInterval = 0
var pickedTurn = 0.0 # log-only: |turn| to the tile that was chosen
var pickedPromoted = false ## log-only: the pick had to break the heat filter
var pickedHeld = false ## j153: the safe set was EMPTY -> hold this tick
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
type TileRef = tuple[col, row: int]
if m.commitTicks == 0:
let hull = computeReachableHull(ws.selfX, ws.selfY, ws.selfHeading, ws.selfSpeed,
m.arenaWidth, m.arenaHeight, HullTicks)
# store as named-field seq to match cachedHull type
m.cachedHull = @[]
for p in hull: m.cachedHull.add (x: p[0], y: p[1])
m.cachedInsideTiles = @[]
if hull.len >= 3:
for row in 0..<m.rows:
for col in 0..<m.cols:
let cx = m.marginX + (col.float + 0.5) * GridSize
let cy = m.marginY + (row.float + 0.5) * GridSize
if pointInHull(cx, cy, hull):
m.cachedInsideTiles.add (col: col, row: row)
let insideTiles = m.cachedInsideTiles
# Find the CoolestLevels distinct lava values among inside-hull tiles
var distinctVals: seq[float]
for t in insideTiles:
let v = m.lavaAt(t.col, t.row)
var found = false
for dv in distinctVals:
if dv == v: found = true; break
if not found: distinctVals.add v
# Sort ascending (insertion sort — small N)
for i in 1..<distinctVals.len:
let key = distinctVals[i]
var j = i - 1
while j >= 0 and distinctVals[j] > key:
distinctVals[j + 1] = distinctVals[j]
dec j
distinctVals[j + 1] = key
# Collect tiles matching the CoolestLevels coolest distinct values
var coolTiles: seq[TileRef]
let numLevels = min(CoolestLevels, distinctVals.len)
for t in insideTiles:
let v = m.lavaAt(t.col, t.row)
for li in 0..<numLevels:
if v == distinctVals[li]:
coolTiles.add t
break
# Score each cool tile by MAX lava on the straight-line path from bot.
# A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe.
const PathSampleStep = 18.0 # ~half a tile
# j150: was `const 10.0`; now the env-overridable var whose DEFAULT is 10.0.
let PathDangerThreshold = TfilDangerThreshold # max lava on path; above = unsafe
# j145: `turnDeg` is the |heading change| from the direction we are ALREADY
# travelling to the tile centre. It is carried on the candidate (never folded
# into `pathMaxHeat`) so the pick can bias among the safe tiles only.
type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float;
arriveTicks: float]
proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float =
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
## ~half a tile. One function so the picker and the j144 hysteresis test
## score the committed target and the alternatives on the SAME scale.
let ddx = tx - fx
let ddy = ty - fy
let lineDist = sqrt(ddx*ddx + ddy*ddy)
if lineDist <= 0.1: return 0.0
let steps = max(1, int(lineDist / PathSampleStep))
var h = 0.0
for si in 0..steps:
let frac = si.float / steps.float
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
h = max(h, m.lavaAt(sc, sr))
h
proc tileOffTravel(m: TFILModule, col, row: int,
sx, sy, travelDeg: float): float =
## Signed angle in degrees from the travel direction to the tile centre,
## folded into (-180, 180]. Positive = the tile lies counterclockwise.
let tx = m.marginX + (col.float + 0.5) * GridSize
let ty = m.marginY + (row.float + 0.5) * GridSize
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
while result > 180.0: result -= 360.0
while result < -180.0: result += 360.0
# j145: the committed travel direction, needed both to score the turn cost of
# every candidate and to draw from it. Computed here (not in the pick block)
# because the candidates are scored before the pick.
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
else: ws.selfHeading
var scoredTiles: seq[ScoredTile]
for t in coolTiles:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
scoredTiles.add (col: t.col, row: t.row,
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty),
turnDeg: abs(tileOffTravel(m, t.col, t.row, ws.selfX,
ws.selfY, travelDeg)),
arriveTicks: sqrt((tx - ws.selfX)^2 + (ty - ws.selfY)^2) / MaxSpeed)
# Sort by pathMaxHeat ascending (insertion sort — small N)
for i in 1..<scoredTiles.len:
let key = scoredTiles[i]
var j = i - 1
while j >= 0 and scoredTiles[j].pathMaxHeat > key.pathMaxHeat:
scoredTiles[j + 1] = scoredTiles[j]
dec j
scoredTiles[j + 1] = key
# Absolute threshold filter: safe = path max lava <= PathDangerThreshold.
# Fallback: if everything is hot, keep the 2 coolest paths anyway.
var safeTiles: seq[ScoredTile]
var blockedTiles: seq[ScoredTile]
m.lastPickPromoted = false # j145: set only by the promotion below, so it is
# always the flag OF THE PICK THAT JUST HAPPENED
for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
else: blockedTiles.add t
let safePre = safeTiles.len # j150: the safe set BEFORE the "keep 2" promotion
let safeEmpty = safePre == 0
if safeTiles.len < 2:
# Fallback: promote the least-hot blocked tiles until we have 2. j154 runs
# this EVEN when a hold is armed, so the panic release has a real fallback to
# fall back ON; the hold only ever DISCARDS the promotion, at the pick site.
# ponytail: O(n) scan on already-sorted seq — fine for small N
let needed = 2 - safeTiles.len
let promote = min(needed, blockedTiles.len)
for i in 0..<promote:
safeTiles.add blockedTiles[i]
m.lastPickPromoted = true
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
# j151: the arrival bound, applied to the pool the draw runs on (hysteresis
# included) so every consumer sees the same set. It never empties the pool:
# if nothing is within the horizon, the full pool is used, exactly as today.
if TfilArriveTicks > 0.0:
var withinHorizon: seq[ScoredTile]
for t in safeTiles:
if t.arriveTicks <= TfilArriveTicks: withinHorizon.add t
if withinHorizon.len > 0: safeTiles = withinHorizon
# Commitment logic. With every j144 knob at its default (all off) this is the
# original three-way test, unchanged. j144 adds two ways OUT of a commitment
# that are NOT a tile crossing, and turns the tick counter into a MINIMUM
# dwell: the target is held until we are actually standing on it.
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
ArriveRadius * ArriveRadius
var commitEnd = rrNone
if m.commitTicks > 0:
inc m.commitAge
# Only allow a replan after MinCommitTicks have elapsed
let ticksElapsed = TfilCommitTicks - m.commitTicks
if ticksElapsed >= MinCommitTicks:
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let curLava = m.lavaAt(cc, cr)
if curLava > m.commitLava + DangerReplanThreshold:
# GENUINE DANGER: the committed tile got hot. Block it so we don't
# immediately re-pick it, and replan. This is the safety valve and is
# deliberately independent of any boundary crossing.
m.blockedTile = (col: cc, row: cr, active: true)
commitEnd = rrDanger
elif TfilCommitArrival:
if atTarget:
# Reached. Only now is a new target allowed.
commitEnd = rrArrival
elif m.commitAge >= HullTicks:
# Stall escape: past the planner's own reachability horizon the
# committed tile is no longer guaranteed reachable (rammed, boxed in).
commitEnd = rrExpiry
elif TfilCommitMargin > 0.0:
# Hysteresis: "the path is still good" must not be able to switch us.
# Leave only when the best OTHER safe tile is cooler by > margin,
# measured on the same pathMaxHeat scale the picker uses.
var altBest = Inf
for t in safeTiles:
if t.col == cc and t.row == cr: continue
altBest = min(altBest, t.pathMaxHeat)
if altBest < Inf and
altBest < pathMaxHeat(m, ws.selfX, ws.selfY, m.commitTarget.x,
m.commitTarget.y) - TfilCommitMargin:
commitEnd = rrHyst
if commitEnd == rrNone:
dec m.commitTicks
if m.commitTicks == 0:
if TfilCommitArrival:
m.commitTicks = TfilCommitTicks # minimum dwell reached: renew, don't abandon
else:
m.replanReason = rrExpiry
else:
m.commitTicks = 0
m.replanReason = commitEnd
# j144: was the commitment we are about to replace still UNREACHED? A pick that
# replaces a target we had not yet got to is the owner's failure mode: the bot
# is still accelerating and the target flips under it. `picks == 0` means this
# is the first pick of the round, which is not a switch at all.
let midFlight = m.picks > 0 and not atTarget
# ── j153/j154: THE HOLD, decided here and nowhere else ─────────────────────
# The safe set was EMPTY, so there is nothing good to walk to. Today's answer
# is the promote-the-2 fallback above; the owner's answer is to stand still and
# let the field change. It is decided HERE, after the commitment block, so
# "are we on a replan tick?" is already answered — a hold replaces a REPLAN
# and can never interrupt a live commitment (j153's comment said that; its
# code did not enforce it, and a mid-commitment hold silently froze the bot).
var doPick = m.commitTicks == 0 and safeTiles.len > 0
if m.commitTicks == 0 and safeEmpty:
let budgeted = TfilHoldMaxTicks > 0
let hold =
if budgeted:
# The BOUNDED hold: at most N ticks per empty streak, released the tick
# a safe tile exists, and overridden outright by an inbound bullet.
m.holdTicks < TfilHoldMaxTicks and
not bulletPanic(m, ws.selfX, ws.selfY,
min(TfilHoldMaxTicks.float, HoldPanicTicks))
else:
TfilHoldWhenTrapped # j153's ONE-tick hold, unchanged
if hold:
pickedHeld = true
doPick = false
if budgeted: inc m.holdTicks
if doPick:
# Filter out the blocked tile from candidates
var candidates: seq[ScoredTile]
for t in safeTiles:
if m.blockedTile.active and t.col == m.blockedTile.col and t.row == m.blockedTile.row:
continue
candidates.add t
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
# j144, no opposite-direction flip while still accelerating. Below the speed
# threshold the bot physically cannot complete a reversal before the bullet
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
# we are already standing on the committed tile (an arrival pick) the bot is
# free to go anywhere, and that is exactly the pick that must not be blocked.
if TfilNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilNoRevSpeed and midFlight:
var offs: seq[float]
for t in candidates:
offs.add tileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
let keep = norevPool(offs, TfilNoRevSpeed)
var narrowed: seq[ScoredTile]
for i in keep: narrowed.add candidates[i]
candidates = narrowed
var chosen = 0
# j152: geometry shapes the DRAW, on top of the heat filter (never instead
# of it). Off by default: with `TR_TFIL_GEO_MODE=off` this whole block is
# skipped and the draw below is byte-for-byte today's.
# ponytail: takes precedence over TfilNoRev/TfilTurnBias (both also default
# off) instead of composing weights; compose if two are ever armed at once.
let geoOn = TfilGeoMode != gdoOff and TfilGeoTau > 0.0
if geoOn and candidates.len >= 2:
var gturns: seq[float]
var gttas: seq[float]
for t in candidates:
gturns.add t.turnDeg
gttas.add t.arriveTicks
chosen = geoPick(gturns, gttas, TfilGeoMode, TfilGeoForm, TfilGeoTau)
elif (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2:
# Soft preferences — down-weight, never filter, and only ever among tiles
# that already passed the hard heat filter above:
# arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward,
# a BINARY cut: it cannot tell 20 deg from 90, nor 91 from 179.
# j145 (TR_TFIL_TURN_BIAS, off by default) — a CONTINUOUS turn cost,
# `turnWeights` = 1 + bias * (1 - excess/180).
# The two multiply, so turning one on never silently drops the other.
var weights = if TfilTurnBias > 0.0:
block:
var turns: seq[float]
for t in candidates: turns.add t.turnDeg
turnWeights(turns, TfilTurnBias, TfilTurnRefDeg)
else:
var w1 = newSeq[int](candidates.len)
for i in 0..<w1.len: w1[i] = 1
w1
if TfilNoRev:
var dirs: seq[float]
for t in candidates:
let tx = m.marginX + (t.col.float + 0.5) * GridSize
let ty = m.marginY + (t.row.float + 0.5) * GridSize
dirs.add arctan2(ty - ws.selfY, tx - ws.selfX) * 180.0 / PI
let nw = noRevWeights(dirs, travelDeg)
for i in 0..<nw.len: weights[i] = weights[i] * nw[i]
var total = 0
var wMax = 0
for w in weights:
total += w
wMax = max(wMax, w)
if wMax <= 1:
# Fallback: no candidate is preferred (every tile is equally bad, or all
# are rearward) → uniform draw, so the pool can never empty and the pick
# degrades to exactly the shipped one.
chosen = rand(candidates.high)
else:
let r = rand(total - 1)
var acc = 0
chosen = weights.high
for i in 0..<weights.len:
acc += weights[i]
if r < acc:
chosen = i
break
else:
chosen = rand(candidates.high)
let ct = candidates[chosen]
pickedPromoted = m.lastPickPromoted
var minTurn = Inf
for t in candidates: minTurn = min(minTurn, t.turnDeg)
m.lastPickMinTurn = if minTurn == Inf: ct.turnDeg else: minTurn
m.lastPickSafe = candidates.len
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
y: m.marginY + (ct.row.float + 0.5) * GridSize)
m.commitTicks = TfilCommitTicks
m.commitAge = 0
m.commitLava = m.lavaAt(ct.col, ct.row)
m.holdTicks = 0 # j154: a safe tile was taken -> the budget refills
m.blockedTile.active = false # clear after successful pick
# log-only: reversal test against the travel direction
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
180.0 / PI - travelDeg
while rd > 180.0: rd -= 360.0
while rd < -180.0: rd += 360.0
pickedThisTick = true
pickedRev = abs(rd) > 90.0
pickedTurn = abs(rd)
pickedMidFlight = midFlight
pickedInterval = m.callCount - m.lastPickCall
m.lastPickCall = m.callCount
inc m.picks
if TfilDiag: # j150: where the tiles died, one row per pick. No effect.
inc TfilLoss.picks
TfilLoss.sReach += insideTiles.len
TfilLoss.sCool += coolTiles.len
TfilLoss.sSafe += safePre
TfilLoss.sCand += candidates.len
if safePre < 2: inc TfilLoss.emptySafe
let b = (if safePre == 0: 0 elif safePre == 1: 1
elif safePre <= 3: 2 elif safePre <= 7: 3
elif safePre <= 15: 4 elif safePre <= 31: 5
elif safePre <= 63: 6 else: 7)
inc TfilLoss.safeHist[b]
# admitted/rejected by the FILTER itself, so the promoted (over-threshold)
# rescue tiles are not counted as safe.
for t in scoredTiles:
if t.pathMaxHeat <= PathDangerThreshold: TfilLoss.admittedHeat.add t.pathMaxHeat
else: TfilLoss.rejectedHeat.add t.pathMaxHeat
TfilLoss.chosenHeat.add ct.pathMaxHeat
if m.debugGraphics:
# Reachable hull perimeter (darker blue)
if m.cachedHull.len >= 3:
let hullPairs: seq[(float, float)] = block:
var s: seq[(float, float)]
for p in m.cachedHull: s.add (p.x, p.y)
s
setStrokeColor(fromHex("#336699"))
setStrokeWidth(1.0)
drawPolygon(hullPairs)
# Dim (dark cyan) for path-blocked cool tiles
setStrokeColor(fromHex("#006666"))
setStrokeWidth(1.0)
for t in blockedTiles:
let x0 = m.marginX + t.col.float * GridSize
let y0 = m.marginY + t.row.float * GridSize
drawRectangle(x0, y0, GridSize, GridSize)
# Bright cyan borders on safe-to-reach tiles
setStrokeColor(fromHex("#00FFFF"))
setStrokeWidth(2.0)
for t in safeTiles:
let x0 = m.marginX + t.col.float * GridSize
let y0 = m.marginY + t.row.float * GridSize
drawRectangle(x0, y0, GridSize, GridSize)
# Green on chosen tile
let (chosenCol, chosenRow) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
let gx0 = m.marginX + chosenCol.float * GridSize
let gy0 = m.marginY + chosenRow.float * GridSize
setStrokeColor(fromHex("#00FF00"))
setStrokeWidth(2.5)
drawRectangle(gx0, gy0, GridSize, GridSize)
# Blue on bot tile
let bx0 = m.marginX + botCol.float * GridSize
let by0 = m.marginY + botRow.float * GridSize
setStrokeColor(fromHex("#0088FF"))
setStrokeWidth(2.5)
drawRectangle(bx0, by0, GridSize, GridSize)
# Committed target line
setStrokeColor(fromHex("#00FF00"))
setStrokeWidth(1.5)
drawLine(ws.selfX, ws.selfY, m.commitTarget.x, m.commitTarget.y)
# Update position snapshot and call counter for next gap detection
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)
if ws.enemies.len > 0:
m.lastEnemyTileCol = clamp(int((ws.enemies[0].x - m.marginX) / GridSize), 0, m.cols - 1)
m.lastEnemyTileRow = clamp(int((ws.enemies[0].y - m.marginY) / GridSize), 0, m.rows - 1)
else:
m.lastEnemyTileCol = -1
m.lastEnemyTileRow = -1
m.callCount += 1
if TfilCommitLogPath.len > 0:
let reason = if pickedThisTick:
(if m.replanReason == rrNone: rrInit else: m.replanReason)
else: rrNone
tfilLogWrite("{\"tick\":" & $ws.tick & ",\"call\":" & $m.callCount &
",\"sp\":" & $ws.selfSpeed & ",\"ct\":" & $m.commitTicks &
",\"pick\":" & (if pickedThisTick: "1" else: "0") &
",\"turn\":" & $pickedTurn &
",\"promote\":" & (if pickedPromoted: "1" else: "0") &
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
(if pickedRev: "1" else: "0") & ",\"mid\":" &
(if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval &
",\"hold\":" & (if pickedHeld: "1" else: "0") &
",\"ht\":" & $m.holdTicks & ## j154: ticks held in the current streak
",\"picks\":" & $m.picks & "}")
if pickedThisTick: m.replanReason = rrNone
m.lastHeld = pickedHeld
# ── Steering ─────────────────────────────────────────────────────────────────
# j153/j154: HOLD. `speed: 0.0` is how this module already says "stop" (the
# already-at-target case below returns the same command), so holding needs no
# new signal, and the hold path is byte-identical to the stop path the bot
# already emits every time it reaches its dodge tile. The gun is NOT in this
# module — computeMove never touches fire, and `ModularBot.nim` aims and
# fires from tracked state AFTER `go()` on every tick regardless of the speed
# it just commanded — so a held tick still fires exactly as before. Guarded in
# `test_tfil_commit_env.nim` (the fire detector still latches a wave on a
# held tick).
if pickedHeld:
return (speed: 0.0, turnRate: 0.0)
let stepDx = m.commitTarget.x - ws.selfX
let stepDy = m.commitTarget.y - ws.selfY
let dist2 = stepDx*stepDx + stepDy*stepDy
if dist2 < 324.0: # already at target (18px radius)
return (speed: 0.0, turnRate: 0.0)
let targetBearing = arctan2(stepDy, stepDx) * 180.0 / PI
var delta = targetBearing - ws.selfHeading
while delta > 180.0: delta -= 360.0
while delta < -180.0: delta += 360.0
let maxTurnRate = 10.0 - 0.75 * abs(ws.selfSpeed)
var speed: float
var turnRate: float
if abs(delta) <= 90.0:
speed = MaxSpeed
turnRate = clamp(delta, -maxTurnRate, maxTurnRate)
else:
let flipped = if delta > 0.0: delta - 180.0 else: delta + 180.0
speed = -MaxSpeed
turnRate = clamp(flipped, -maxTurnRate, maxTurnRate)
result = (speed: speed, turnRate: turnRate)