## 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 5.0 lava per corridor-overlapping tile ## TR_TFIL_WALL_HOTNESS default 10.0 peak wall radiance at a wall tile ## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick) ## `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 two constants below ## are retuned so that NO SINGLE soft source can poison a path on its own: ## CorridorHeat = 5.0 — below `PathDangerThreshold` (10.0): one corridor can ## no longer make a path unsafe by itself. ## WallHotness = 10.0 — equals the threshold: the outer two tile rings are no ## longer over-threshold from wall radiance alone. ## Both are env-overridable (TR_TFIL_CORRIDOR_HEAT / TR_TFIL_WALL_HOTNESS) for ## an A/B; the defaults are the values measured to unlock the range weighting. ## WallRadiance, PillarHotness/Radiance, Enemy*/Bullet* and PathDangerThreshold ## are UNCHANGED from the original mover. import std/[math, random, os, strformat] from std/strutils import parseFloat, strip # selective: strutils.fromHex clashes with color.fromHex import gun_harness/gun_interface import movement_harness/movement_interface 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 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.. 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.. 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] prevEnergy: seq[tuple[id: int, energy: float]] # enemy id -> last known energy 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) 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)) 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 = for e in m.prevEnergy: if e.id == id: return e.energy 100.0 proc prevEnergySet(m: var TFILRingModule, id: int, energy: float) = for i in 0..= 0.09 and drop <= 3.01: let speed = 20.0 - 3.0 * drop # Linear prediction: aim at where we will be when the bullet arrives 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: drop, alive: true, age: 0) 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.. 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.. 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.prevEnergy = @[] for ei in ws.enemies: m.prevEnergySet(ei.id, ei.energy) # Tile-change replan: catches gradual displacement that position threshold misses if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0): 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..= 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.. maxLava: maxLava = v # Non-zero tiles: colored border + colored value text (yellow→orange→red) setFont("Arial", 10.0) for row in 0.. 0.0: val / maxLava else: 0.0 let heatColor = fromRgb(255'u8, uint8(255.0 * (1.0 - t)), 0'u8) let x0 = m.marginX + col.float * GridSize let y0 = m.marginY + row.float * GridSize setStrokeColor(heatColor) setStrokeWidth(1.0) drawRectangle(x0, y0, GridSize, GridSize) setFillColor(heatColor) drawText($int(val), x0 + 12.0, y0 + 22.0) # 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..= 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.. 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..= 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.. 0: # Only allow danger 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) if curLava > m.commitLava + DangerReplanThreshold: # Mark committed tile blocked so we don't re-pick it m.blockedTile = (col: cc, row: cr, active: true) m.commitTicks = 0 # replan else: dec m.commitTicks else: dec m.commitTicks 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 # 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.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)