## 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) const BulletCore = 10.0 ## lava accumulation per bullet-overlapping tile const BulletAura = 5.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 ## 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 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`. type TfilTileReplan* = enum ttrSelf, ttrOff, ttrEnemy TfilReplanReason* = enum rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry 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" const DefaultTfilCommitTicks = CommitTicks ## 15 — the shipped commitment length NoRevForwardWeight = 3 ## forward:backward weight ratio (arm C) var TfilTileReplanMode*: TfilTileReplan = ttrSelf TfilCommitTicks*: int = DefaultTfilCommitTicks TfilNoRev*: bool = false TfilCommitLogPath*: string = "" ## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on). ## Off = the shipped `prev - energy` detector byte-for-byte. TfilFireFix*: bool = true 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", "") TfilFireFix = getEnvBool("TR_FIRE_FIX", true) 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)) 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() 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 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) 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.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 # ── 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 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 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) m.fire.endScan() 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 = 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: 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.. 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.. 180.0: a -= 360.0 while a < -180.0: a += 360.0 result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1) 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. if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0): 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.. 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.. 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 # 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 pickedInterval = 0 # 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 = 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: # Mark committed tile blocked so we don't re-pick it m.blockedTile = (col: cc, row: cr, active: true) m.commitTicks = 0 # replan m.replanReason = rrDanger else: dec m.commitTicks if m.commitTicks == 0: m.replanReason = rrExpiry else: dec m.commitTicks if m.commitTicks == 0: m.replanReason = rrExpiry 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 let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0 else: ws.selfHeading var chosen = 0 if TfilNoRev and candidates.len >= 2: # Soft no-reversal preference (arm C): down-weight — never filter — tiles # that lie >90 deg from the current travel direction. 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 weights = noRevWeights(dirs, travelDeg) var total = 0 var forward = 0 for w in weights: total += w if w > 1: inc forward if forward == 0: # Fallback: no forward tile exists → uniform draw, so the pool can # never empty and the pick is identical to the shipped one. chosen = rand(candidates.high) else: let r = rand(total - 1) var acc = 0 chosen = weights.high for i in 0.. 180.0: rd -= 360.0 while rd < -180.0: rd += 360.0 pickedThisTick = true pickedRev = abs(rd) > 90.0 pickedInterval = m.callCount - m.lastPickCall m.lastPickCall = m.callCount inc m.picks 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") & ",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" & (if pickedRev: "1" else: "0") & ",\"interval\":" & $pickedInterval & ",\"picks\":" & $m.picks & "}") if pickedThisTick: m.replanReason = rrNone # ── 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)