fe77056459
The_floor_is_lava_ring carried raw commitTicks with no arrival guard and no
no-reversal guard. Port the behaviour of tfil's j144 fix on RING-SPECIFIC env
names (TR_TFIL_RING_COMMIT_ARRIVAL, TR_TFIL_RING_NOREV_SPEED) so the two forks
never share a namespace. Both default OFF: with them unset the ring mover is
byte-for-byte the pre-change mover over the whole 20026-tick fixture replay
(golden generated from git show HEAD:..., checked by tfil_ring_replay.nim).
Structural differences from tfil, all noted in the code:
* ring has no TfilTileReplanMode - the tile-crossing cancel is unconditional
self-tile, so the arrival guard is just 'not TfilRingCommitArrival'.
* ring has no replanReason enum, so the arrival/danger/expiry outcomes are a
local bool; the default-off path keeps ring's original dec/no-dec exactly.
* ring's MinCommitTicks is 0 (tfil's is 5), so the arrival branch is evaluated
from the first committed tick. Left as is: changing it would change the
default path.
* ring's ScoredTile carries no turnDeg, so the no-reversal offsets are
computed by ringTileOffTravel at the pick site.
TR_TFIL_COMMIT_MARGIN (tfil's hysteresis) is deliberately NOT ported: it is a
third knob, outside the two named, and inert at its 0.0 default.
No other tfil mechanism touched: no turn-cost tiebreak, TR_TFIL_ARRIVE_TICKS,
TR_FIRE_LAG, heat-field override, corridor bound, hold, or geometry weighting.
No default changed anywhere.
1055 lines
47 KiB
Nim
1055 lines
47 KiB
Nim
## TFIL-RING — The Floor Is Lava, range-weighted. A COPY of
|
||
## `the_floor_is_lava.nim` that keeps that mover's semantics and adds ONE
|
||
## behavioural lever: the per-tick random tile pick is re-weighted so tiles at
|
||
## the bot's preferred TARGET RANGE are drawn more often.
|
||
##
|
||
## ── Why ─────────────────────────────────────────────────────────────────────
|
||
## ModularBot's measured real hit rate vs DrussGT is strongly range-dependent
|
||
## (21.6% at 0-100px, 27.1% at 100-200px, then falling off: 19.3% at 200-300,
|
||
## 10.9% at 300-400, 6.8% at 400-600, 5.4% at 600-800). The bot shoots from
|
||
## ~450px on average, where it hits ~5%. The plain TFIL mover has NO range
|
||
## preference, so it drifts; this mover SHIFTS the random distribution toward a
|
||
## configured band. 100-200 is DrussGT-only data and MUST be re-measured per
|
||
## adversary (set TR_TFIL_RANGE_LO/HI).
|
||
##
|
||
## ── What is preserved (safety stays a HARD constraint) ──────────────────────
|
||
## The reachable hull, the cool-tile pool, the path-heat scoring/sort, the
|
||
## `PathDangerThreshold = 10.0` safety filter and the `safeTiles.len < 2`
|
||
## promote fallback are IDENTICAL to `the_floor_is_lava.nim`. The weighting is
|
||
## applied ONLY to the final draw over `candidates`, which is exactly the pool
|
||
## the unweighted `rand(candidates.high)` picked from. It can therefore never
|
||
## select a tile the old code would have rejected — a monotone refinement of
|
||
## the same safe set. The per-tick randomness is deliberately KEPT (a measured
|
||
## A/B showed committing to the "best" tile made hit rate WORSE, 7.02% ->
|
||
## 5.10%); only its distribution is tilted, never removed.
|
||
##
|
||
## ── The one change ──────────────────────────────────────────────────────────
|
||
## For each candidate tile, d = distance from the tile CENTRE to the current
|
||
## TARGET enemy (ws.enemyX/enemyY). Band weight with a FLAT TOP:
|
||
##
|
||
## rangeW(d) = 1.0 if lo <= d <= hi
|
||
## exp(-((lo - d)/K)^2) if d < lo
|
||
## exp(-((d - hi)/K)^2) if d > hi
|
||
##
|
||
## The flat top matters: a Gaussian centred on the band midpoint would collapse
|
||
## the band to a point and destroy the within-band hedge. Then a
|
||
## temperature-shaped categorical draw:
|
||
##
|
||
## w_i = rangeW(d_i) ^ (1/T); chosen ~ Categorical(w)
|
||
##
|
||
## T -> infinity gives uniform weights (the old distribution). Small safe pools
|
||
## (len < 4, i.e. melee) are always drawn uniformly, so shaping cannot drag the
|
||
## bot toward the enemy while the tiles are hot.
|
||
##
|
||
## ── Env knobs (read once at module init, like the gun rack) ─────────────────
|
||
## TR_TFIL_RANGE_LO default 100.0 band lower edge (px)
|
||
## TR_TFIL_RANGE_HI default 200.0 band upper edge (px)
|
||
## TR_TFIL_RANGE_TEMP default 0.4 softmax temperature; <= 0 = OFF path
|
||
## TR_TFIL_RANGE_K default 60.0 Gaussian falloff scale (px)
|
||
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
|
||
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
|
||
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
|
||
## TR_TFIL_RING_COMMIT_ARRIVAL default off hold the committed tile until we
|
||
## are ON it (port of tfil's j144 fix)
|
||
## TR_TFIL_RING_NOREV_SPEED default 0.0 px/tick; below this a mid-flight
|
||
## switch may not turn the bot around
|
||
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
|
||
## original mover did, so the same binary can serve as the control arm.
|
||
##
|
||
## ── Heat-field knobs (the deviation from the original TFIL) ─────────────────
|
||
## Measured (offline, DrussGT fixtures): corridors are 21.4% and walls 56.4% of
|
||
## the over-threshold set, so they dominate the field. The ACTUAL values in this
|
||
## file (read at module init, `let` block further down) are:
|
||
## CorridorHeat = 10.0 — exactly `PathDangerThreshold` (10.0). The safety rule
|
||
## is `pathMaxHeat <= PathDangerThreshold`, so a corridor
|
||
## tile sitting on a path is still SAFE: a corridor alone
|
||
## can no longer make a path unsafe.
|
||
## WallHotness = 15.0 — ABOVE the threshold at the OUTERMOST ring only (the
|
||
## second ring sits at exactly 10.0, the threshold, and
|
||
## is safe), so wall radiance alone CAN poison the outer
|
||
## ring but no deeper.
|
||
## WallRadiance = 5.0 — the falloff that produces the two rings above.
|
||
## BulletCore/BulletAura = 20.0/10.0 — the bullet's own core is ABOVE the
|
||
## threshold, which is what makes an incoming bullet
|
||
## dangerous on its own.
|
||
## Both soft knobs are env-overridable (TR_TFIL_CORRIDOR_HEAT /
|
||
## TR_TFIL_WALL_HOTNESS) for an A/B.
|
||
## CHANGED vs the original mover: BulletCore/BulletAura (20/10 here, 10/5 in
|
||
## the original), WallRadiance (5.0 here, 10.0 in the original). UNCHANGED:
|
||
## EnemyCore/EnemyAura and their radii, PillarHotness/PillarRadiance (0/0
|
||
## default) and PathDangerThreshold (10.0).
|
||
##
|
||
## WARNING: an earlier revision of THIS comment claimed CorridorHeat = 5.0 and
|
||
## WallHotness = 10.0. That did NOT match the code — it was a stale copy of an
|
||
## earlier retune PROPOSAL, and a job that read the comment handed out
|
||
## sub-threshold heat values that left the field empty. The code has always been
|
||
## 10.0 / 15.0; the comment now says so.
|
||
|
||
import std/[math, random, os, strformat]
|
||
from std/strutils import parseFloat, strip, toLowerAscii # selective: strutils.fromHex clashes 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)
|
||
|
||
const BulletCore = 20.0 ## lava accumulation per bullet-overlapping tile
|
||
const BulletAura = 10.0 ## lava accumulation for aura ring tiles
|
||
|
||
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
|
||
|
||
## NOTE: CorridorHeat and WallHotness are NOT const here (unlike the original
|
||
## `the_floor_is_lava.nim`): they are env-overridable and read at module init,
|
||
## below, in the same style as the range-weighting knobs.
|
||
const WallRadiance = 5.0
|
||
|
||
const PillarHotness = 0.0
|
||
const PillarRadiance = 0.0
|
||
|
||
const CommitTicks = 5 ## ticks to commit to a dodge point
|
||
const MinCommitTicks = 0 ## must commit for this many ticks before danger replan allowed
|
||
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
|
||
|
||
# ── Range-weighting knobs (read once at module init, like the gun rack) ──────
|
||
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 getEnvBool(name: string, default: bool): bool =
|
||
let s = getEnv(name, "").strip().toLowerAscii()
|
||
if s.len == 0: return default
|
||
s in ["1", "true", "on", "yes"]
|
||
|
||
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
|
||
## Off = the shipped `prev - energy` detector byte-for-byte.
|
||
var TfilRingFireFix* = true
|
||
proc loadTfilRingFireEnv*() =
|
||
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||
loadTfilRingFireEnv()
|
||
|
||
## ── j165: the ARRIVAL commitment, ported from `the_floor_is_lava.nim` ────────
|
||
## Same BEHAVIOUR as tfil's `TR_TFIL_COMMIT_ARRIVAL` / `TR_TFIL_NOREV_SPEED`,
|
||
## RING-SPECIFIC env names so the two forks never share a namespace by accident.
|
||
## Both default OFF, so the default path stays byte-for-byte today's ring
|
||
## (proved over the 20026-tick fixture replay in `test_tfil_commit_env.nim`).
|
||
## TR_TFIL_RING_COMMIT_ARRIVAL 0/1 hold the committed tile until we are
|
||
## ON it, instead of dropping the
|
||
## commitment on a tile crossing
|
||
## TR_TFIL_RING_NOREV_SPEED float while |speed| is below this, a
|
||
## mid-flight switch to the OPPOSITE
|
||
## side is refused (0 = off)
|
||
const
|
||
RingArriveRadius* = 18.0 ## "we are on the committed tile" — the same 18px
|
||
## radius `the_floor_is_lava.nim` uses
|
||
RingHullTicks = 50 ## the reachable-hull planning horizon (the literal
|
||
## 50 already passed to `computeReachableHull`
|
||
## below). Past it the committed target is no longer
|
||
## guaranteed reachable: the stall escape.
|
||
|
||
var
|
||
TfilRingCommitArrival* = false
|
||
TfilRingNoRevSpeed* = 0.0
|
||
|
||
proc loadTfilRingCommitEnv*() =
|
||
## Read the j165 knobs. Called once at module init; the guard test calls it
|
||
## again after `putEnv` so the non-default arms run in one process.
|
||
TfilRingCommitArrival = getEnvBool("TR_TFIL_RING_COMMIT_ARRIVAL", false)
|
||
TfilRingNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_RING_NOREV_SPEED", 0.0))
|
||
loadTfilRingCommitEnv()
|
||
|
||
const
|
||
DefaultRangeLo = 100.0
|
||
DefaultRangeHi = 200.0
|
||
DefaultRangeTemp = 0.4
|
||
DefaultRangeK = 60.0
|
||
## Minimum safe-pool size before range shaping applies. Below this the draw is
|
||
## uniform: a 2-3 tile melee pool must not be pulled toward the enemy while
|
||
## those tiles are hot.
|
||
MinRingPool = 4
|
||
|
||
let RangeLo* = getEnvFloat("TR_TFIL_RANGE_LO", DefaultRangeLo)
|
||
let RangeHi* = getEnvFloat("TR_TFIL_RANGE_HI", DefaultRangeHi)
|
||
let RangeTemp* = getEnvFloat("TR_TFIL_RANGE_TEMP", DefaultRangeTemp)
|
||
let RangeK* = getEnvFloat("TR_TFIL_RANGE_K", DefaultRangeK)
|
||
let MovementLog* = existsEnv("TR_MOVEMENT_LOG")
|
||
|
||
# ── Heat-field knobs (see the rationale in the file header) ──────────────────
|
||
let CorridorHeat* = getEnvFloat("TR_TFIL_CORRIDOR_HEAT", 10.0)
|
||
let WallHotness* = getEnvFloat("TR_TFIL_WALL_HOTNESS", 15.0)
|
||
|
||
proc rangeWeight*(d, lo, hi, k: float): float =
|
||
## Flat-topped range weight. Exactly 1.0 for d inside [lo, hi]; Gaussian
|
||
## falloff with scale `k` outside. The flat top is deliberate: a Gaussian
|
||
## centred ON the band would collapse it to a point, whereas this keeps every
|
||
## in-band tile equally likely and preserves the within-band hedge. `k <= 0`
|
||
## degrades to a hard band edge (1 inside, 0 outside).
|
||
if d >= lo and d <= hi: return 1.0
|
||
if k <= 0.0: return 0.0
|
||
let z = (if d < lo: (lo - d) / k else: (d - hi) / k)
|
||
exp(-z * z)
|
||
|
||
proc bandWeights*(dists: openArray[float],
|
||
lo, hi, k, temp: float): seq[float] =
|
||
## Categorical weights over a candidate pool. `temp` is the softmax
|
||
## temperature: `temp -> inf` drives every weight to 1.0 (uniform = the old
|
||
## distribution). `temp <= 0` (the explicit OFF path) and pools smaller than
|
||
## `MinRingPool` also return uniform weights.
|
||
result = newSeq[float](dists.len)
|
||
let uniform = temp <= 0.0 or dists.len < MinRingPool
|
||
for i in 0..<dists.len:
|
||
if uniform:
|
||
result[i] = 1.0
|
||
else:
|
||
result[i] = pow(rangeWeight(dists[i], lo, hi, k), 1.0 / temp)
|
||
|
||
proc weightedIndex*(weights: openArray[float], u: float): int =
|
||
## PURE categorical draw: given weights proportional to the probabilities and
|
||
## a uniform variate `u` in [0, 1), return the selected index in
|
||
## 0..<weights.len. The RNG output is injected as `u`, so this is
|
||
## unit-testable with no battle. It always returns an index INSIDE the pool,
|
||
## never outside. Falls back to a uniform index when the weights sum to zero
|
||
## (keeps the function total; with the default K every weight is > 0).
|
||
if weights.len == 0: return 0
|
||
var total = 0.0
|
||
for w in weights: total += w
|
||
if not (total > 0.0):
|
||
return min(weights.high, int(u * weights.len.float))
|
||
let target = u * total
|
||
var acc = 0.0
|
||
for i in 0..<weights.len:
|
||
acc += weights[i]
|
||
if target < acc: return i
|
||
weights.high
|
||
|
||
proc weightedIndex*(weights: openArray[float], rng: var Rand): int =
|
||
## Convenience wrapper for callers holding a `Rand` to inject.
|
||
weightedIndex(weights, rand(rng, 1.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
|
||
|
||
TFILRingModule* = object
|
||
debugGraphics*: bool
|
||
## Preferred target-range band (px from the target enemy). The BOT sets this
|
||
## each tick from the existing ram decision: (RangeLo, RangeHi) while
|
||
## holding, (0.0, 50.0) while ramming. Ram is therefore just a band, so one
|
||
## movement engine covers both modes. Defaults to (RangeLo, RangeHi).
|
||
band*: tuple[lo, hi: float]
|
||
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
|
||
commitLava: float ## lava at commit time (for spike detection)
|
||
commitAge: int ## j165: ticks since the current target
|
||
## was picked (0 = just picked)
|
||
picks: int ## j165: picks made this round; > 0 means
|
||
## a switch would be MID-FLIGHT
|
||
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
|
||
# Ring-mode observability (TR_MOVEMENT_LOG): last logged range class and
|
||
# band, so the log fires on change rather than every tick.
|
||
lastLogClass: int ## -1 = never; 0 near, 1 in-band, 2 far
|
||
lastLogBandLo: float
|
||
lastLogBandHi: float
|
||
loggedOnce: bool
|
||
|
||
proc initTFILRing*(): TFILRingModule =
|
||
TFILRingModule(debugGraphics: false, band: (lo: RangeLo, hi: RangeHi),
|
||
fire: initFireTracker())
|
||
|
||
proc removeBulletNear*(m: var TFILRingModule, 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: TFILRingModule, id: int): float = m.fire.prevEnergyGet(id)
|
||
|
||
proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) =
|
||
m.fire.prevEnergySet(id, energy)
|
||
|
||
proc clearGraphics*(m: var TFILRingModule) =
|
||
## 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 TFILRingModule) =
|
||
m.bullets = @[]
|
||
m.fire.reset()
|
||
m.commitTicks = 0
|
||
m.commitAge = 0
|
||
m.picks = 0
|
||
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.band = (lo: RangeLo, hi: RangeHi)
|
||
m.lastLogClass = -1
|
||
m.lastLogBandLo = RangeLo
|
||
m.lastLogBandHi = RangeHi
|
||
m.loggedOnce = false
|
||
|
||
proc initGrid(m: var TFILRingModule, 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 TFILRingModule, ws: WorldState, ei: EnemyInfo,
|
||
power: float) =
|
||
## One tracked bullet/wave for a confirmed fire of `power` (extracted from
|
||
## `detectFires` 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
|
||
m.bullets.add TrackedBullet(
|
||
originX: ei.x, originY: ei.y,
|
||
x: ei.x, y: ei.y,
|
||
velX: speed * cos(heading),
|
||
velY: speed * sin(heading),
|
||
power: power,
|
||
alive: true,
|
||
age: 0)
|
||
|
||
proc noteEnemyBulletHit*(m: var TFILRingModule, power: float) =
|
||
## `onHitByBullet` -> the shooter's `3*power` bonus (no-op when off).
|
||
if TfilRingFireFix:
|
||
m.fire.noteEnemyBulletHit(power)
|
||
|
||
proc noteDamageDealt*(m: var TFILRingModule, damage: float) =
|
||
## `onBulletHit` -> our same-tick damage to the enemy (no-op when off).
|
||
if TfilRingFireFix:
|
||
m.fire.noteDamageDealt(damage)
|
||
|
||
proc detectFires(m: var TFILRingModule, 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, TfilRingFireFix):
|
||
m.spawnTrackedWave(ws, ei, p)
|
||
m.fire.endScan()
|
||
|
||
proc advanceBullets(m: var TFILRingModule, 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 =
|
||
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)
|
||
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
|
||
|
||
proc lavaAt(m: TFILRingModule, col, row: int): float =
|
||
m.lava[row * m.cols + col]
|
||
|
||
proc tileAt(m: TFILRingModule, 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 ringTileOffTravel*(m: TFILRingModule, col, row: int,
|
||
sx, sy, travelDeg: float): float =
|
||
## Signed angle in degrees from the travel direction to the tile centre,
|
||
## folded into (-180, 180]. Verbatim from `the_floor_is_lava.nim`'s
|
||
## `tileOffTravel`; the ring's `ScoredTile` carries no `turnDeg`, so the
|
||
## no-reversal pool computes the offsets itself.
|
||
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
|
||
|
||
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
|
||
## j165: which candidate tiles may a slow, mid-flight switch take? Verbatim
|
||
## from `the_floor_is_lava.nim`. `offs` are the signed angles (deg) from the
|
||
## travel direction to each candidate, `threshold` is the speed gate
|
||
## (px/tick). Returns the indices NOT more than 90 deg off — the bot does not
|
||
## have to turn around to reach them. If EVERY candidate is behind us the
|
||
## reversal is unavoidable, 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` = the knob is off and every candidate stays.
|
||
if offs.len == 0: return
|
||
if threshold <= 0.0:
|
||
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 computeMove*(m: var TFILRingModule, 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: catches gradual displacement that position threshold misses
|
||
# j165: with TR_TFIL_RING_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 at GridSize 36 / speed 8 it fires every
|
||
# ~5 ticks — which is precisely this fork's CommitTicks. 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 TfilRingCommitArrival:
|
||
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 = @[]
|
||
|
||
# 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)
|
||
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:
|
||
m.lava[row * m.cols + col] += BulletCore
|
||
elif d2 <= auraR * auraR:
|
||
m.lava[row * m.cols + col] += BulletAura
|
||
|
||
# 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)
|
||
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
|
||
|
||
# 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
|
||
let wx = cg.bx + cg.dx * cg.tMin
|
||
let wy = cg.by + cg.dy * cg.tMin
|
||
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)
|
||
|
||
# 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, 50)
|
||
# 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
|
||
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
|
||
type ScoredTile = tuple[col, row: int; pathMaxHeat: float]
|
||
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
|
||
let ddx = tx - ws.selfX
|
||
let ddy = ty - ws.selfY
|
||
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
||
var pathMaxHeat = 0.0
|
||
if lineDist > 0.1:
|
||
let steps = max(1, int(lineDist / PathSampleStep))
|
||
for si in 0..steps:
|
||
let frac = si.float / steps.float
|
||
let sx = ws.selfX + ddx * frac
|
||
let sy = ws.selfY + ddy * frac
|
||
let (sc, sr) = m.tileAt(sx, sy)
|
||
pathMaxHeat = max(pathMaxHeat, m.lavaAt(sc, sr))
|
||
scoredTiles.add (col: t.col, row: t.row, pathMaxHeat: pathMaxHeat)
|
||
|
||
# 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]
|
||
for t in scoredTiles:
|
||
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
|
||
else: blockedTiles.add t
|
||
if safeTiles.len < 2:
|
||
# Fallback: promote the least-hot blocked tiles until we have 2
|
||
# 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]
|
||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||
|
||
# Commitment logic. With every j165 knob at its default (both off) this is the
|
||
# original three-way test, unchanged. j165 adds one way OUT of a commitment
|
||
# that is NOT a tile crossing (the block above is skipped when armed) 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 <
|
||
RingArriveRadius * RingArriveRadius
|
||
if m.commitTicks > 0:
|
||
inc m.commitAge
|
||
# Only allow a replan after MinCommitTicks have elapsed
|
||
let ticksElapsed = CommitTicks - m.commitTicks
|
||
if ticksElapsed >= MinCommitTicks:
|
||
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||
let curLava = m.lavaAt(cc, cr)
|
||
var commitEnd = false
|
||
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 safety valve is deliberately
|
||
# independent of the arrival rule and is UNCHANGED by j165.
|
||
m.blockedTile = (col: cc, row: cr, active: true)
|
||
commitEnd = true
|
||
elif TfilRingCommitArrival:
|
||
if atTarget:
|
||
# Reached. Only now is a new target allowed.
|
||
commitEnd = true
|
||
elif m.commitAge >= RingHullTicks:
|
||
# Stall escape: past the planner's own reachability horizon the
|
||
# committed tile is no longer guaranteed reachable (rammed, boxed in).
|
||
commitEnd = true
|
||
if not commitEnd:
|
||
dec m.commitTicks
|
||
if m.commitTicks == 0 and TfilRingCommitArrival:
|
||
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
|
||
else:
|
||
m.commitTicks = 0
|
||
else:
|
||
dec m.commitTicks
|
||
|
||
# j165: 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
|
||
|
||
if m.commitTicks == 0 and safeTiles.len > 0:
|
||
# 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
|
||
# j165, 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 TfilRingNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilRingNoRevSpeed and midFlight:
|
||
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
|
||
else: ws.selfHeading
|
||
var offs: seq[float]
|
||
for t in candidates:
|
||
offs.add ringTileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
|
||
let keep = norevPool(offs, TfilRingNoRevSpeed)
|
||
var narrowed: seq[ScoredTile]
|
||
for i in keep: narrowed.add candidates[i]
|
||
candidates = narrowed
|
||
# Safety is a HARD constraint: the weighting below only re-orders the draw
|
||
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
|
||
# It can never select a tile the unweighted code would have rejected
|
||
# (monotone refinement of the same safe set).
|
||
var chosen: int
|
||
if RangeTemp <= 0.0:
|
||
# Explicit OFF path: byte-for-byte the old uniform draw (control arm).
|
||
chosen = rand(candidates.high)
|
||
else:
|
||
var dists = newSeq[float](candidates.len)
|
||
for i, t in candidates:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
dists[i] = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||
let weights = bandWeights(dists, m.band.lo, m.band.hi, RangeK, RangeTemp)
|
||
chosen = weightedIndex(weights, rand(1.0))
|
||
let ct = candidates[chosen]
|
||
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
|
||
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
||
m.commitTicks = CommitTicks
|
||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||
m.commitAge = 0
|
||
inc m.picks
|
||
m.blockedTile.active = false # clear after successful pick
|
||
|
||
# One concise log line on a range-class or band change (never per-tick).
|
||
if MovementLog:
|
||
let d = hypot(m.commitTarget.x - ws.enemyX, m.commitTarget.y - ws.enemyY)
|
||
let cls = if d < m.band.lo: 0 elif d <= m.band.hi: 1 else: 2
|
||
if (not m.loggedOnce) or cls != m.lastLogClass or
|
||
m.band.lo != m.lastLogBandLo or m.band.hi != m.lastLogBandHi:
|
||
let clsName = ["near", "band", "far"][cls]
|
||
echo fmt"[tfil_ring] mode=ring band=[{m.band.lo.int},{m.band.hi.int}] " &
|
||
fmt"chosenDist={d.int} class={clsName} cands={candidates.len} " &
|
||
fmt"corridorHeat={CorridorHeat} wallHotness={WallHotness}"
|
||
m.loggedOnce = true
|
||
m.lastLogClass = cls
|
||
m.lastLogBandLo = m.band.lo
|
||
m.lastLogBandHi = m.band.hi
|
||
|
||
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)
|
||
|
||
# ── Ring-mode observability ─────────────────────────────────────────────
|
||
# Target band annulus around the current target enemy: two magenta rings at
|
||
# lo and hi radius read as the annulus between them. (lo == 0 in ram mode,
|
||
# so the inner ring is skipped there.)
|
||
setStrokeColor(fromHex("#FF00FF"))
|
||
setStrokeWidth(1.5)
|
||
if m.band.lo > 0.0: drawCircle(ws.enemyX, ws.enemyY, m.band.lo)
|
||
drawCircle(ws.enemyX, ws.enemyY, m.band.hi)
|
||
|
||
# Tint each safe tile by its range weight (red = 0, green = 1) and thicken
|
||
# the border with the weight, so the shaped distribution is visible.
|
||
setFont("Arial", 9.0)
|
||
for t in safeTiles:
|
||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||
let d = hypot(tx - ws.enemyX, ty - ws.enemyY)
|
||
let w = rangeWeight(d, m.band.lo, m.band.hi, RangeK)
|
||
let x0 = m.marginX + t.col.float * GridSize
|
||
let y0 = m.marginY + t.row.float * GridSize
|
||
setStrokeColor(fromRgb(255'u8, uint8(255.0 * (1.0 - w)), 0'u8))
|
||
setStrokeWidth(1.0 + 3.0 * w)
|
||
drawRectangle(x0, y0, GridSize, GridSize)
|
||
|
||
# Chosen tile: a filled magenta marker, distinct from the tinted candidates.
|
||
setFillColor(fromHex("#FF00FF"))
|
||
fillCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
|
||
setStrokeColor(fromHex("#FFFFFF"))
|
||
setStrokeWidth(1.0)
|
||
drawCircle(m.commitTarget.x, m.commitTarget.y, 6.0)
|
||
|
||
# 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)
|
||
m.callCount += 1
|
||
|
||
# ── Steering ─────────────────────────────────────────────────────────────────
|
||
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)
|