38fbc6ecd1
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.
1623 lines
75 KiB
Nim
1623 lines
75 KiB
Nim
## 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)
|