TFIL: env-gated commitment arms (tile-replan cancel is a bug)
The tile-change replan cancels the 15-tick movement commitment whenever OUR
tile changes. With GridSize=36 and speed up to 8 px/tick that is every ~5
ticks, so the commitment is cancelled by the motion it commands (measured:
96.9% of picks were tile-change replans, 33.8% of picks reversed direction).
Adds four env knobs, every default reproducing the shipped mover
byte-for-byte:
TR_TFIL_TILE_REPLAN self (default) | off | enemy
TR_TFIL_COMMIT_TICKS 15 (default)
TR_TFIL_NO_REV 0 (default)
TR_TFIL_COMMIT_LOG off (default, JSONL per-tick diagnostics)
- `off` honours the commitment; the danger replan stays the safety valve.
- `enemy` keys the cancel to the TARGET's tile displacement (the intent the
original comment claimed).
- `TR_TFIL_NO_REV` down-weights (never filters) tiles >90 deg from the travel
direction; the pool can never be emptied.
Default-path parity is guarded by test_tfil_commit_env.nim, which replays
tools/fixtures/tr_drussgt_vs_modularbot.jsonl and diffs every move command
against a golden generated from the pre-change build (git archive f842ac0).
env_report known-name list updated for the four new names.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
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 robocode_tankroyale_botapi/graphics
|
||||
@@ -38,6 +40,73 @@ const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet co
|
||||
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 = ""
|
||||
|
||||
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 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", "")
|
||||
|
||||
loadTfilCommitEnv()
|
||||
|
||||
proc bulletRadii(power: float): tuple[core, aura: float] =
|
||||
let t = (power - 0.1) / 2.9
|
||||
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
|
||||
@@ -70,6 +139,10 @@ type
|
||||
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)
|
||||
|
||||
@@ -114,6 +187,44 @@ proc resetRound*(m: var TFILModule) =
|
||||
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)
|
||||
@@ -266,6 +377,19 @@ proc computeReachableHull(x0, y0, heading0, speed0,
|
||||
if cur == startIdx: break
|
||||
hull
|
||||
|
||||
proc noRevWeights*(dirs: openArray[float], travelDeg: float): seq[int] =
|
||||
## Soft no-reversal preference (arm C). `dirs` are candidate directions in
|
||||
## degrees (world frame). Forward candidates (<=90 deg off the travel
|
||||
## direction) get `NoRevForwardWeight`, every other candidate gets 1.
|
||||
## A weight is NEVER 0, so the preference can only down-weight, never filter:
|
||||
## the candidate pool can never be emptied by it. When no forward candidate
|
||||
## exists the caller falls back to a uniform draw (all weights 1 anyway).
|
||||
for d in dirs:
|
||||
var a = d - travelDeg
|
||||
while a > 180.0: a -= 360.0
|
||||
while a < -180.0: a += 360.0
|
||||
result.add (if abs(a) <= 90.0: NoRevForwardWeight else: 1)
|
||||
|
||||
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
if m.cols == 0:
|
||||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||
@@ -285,14 +409,30 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
for ei in ws.enemies:
|
||||
m.prevEnergySet(ei.id, ei.energy)
|
||||
|
||||
# Tile-change replan: catches gradual displacement that position threshold misses
|
||||
# Tile-change replan — see the knob rationale at the top of the file.
|
||||
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 = @[]
|
||||
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)
|
||||
@@ -466,6 +606,11 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
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:
|
||||
@@ -563,7 +708,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
# Commitment logic
|
||||
if m.commitTicks > 0:
|
||||
# Only allow danger replan after MinCommitTicks have elapsed
|
||||
let ticksElapsed = CommitTicks - m.commitTicks
|
||||
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)
|
||||
@@ -571,10 +716,13 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
# 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
|
||||
@@ -584,14 +732,56 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
continue
|
||||
candidates.add t
|
||||
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
|
||||
let chosen = rand(candidates.high)
|
||||
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..<weights.len:
|
||||
acc += weights[i]
|
||||
if r < acc:
|
||||
chosen = i
|
||||
break
|
||||
else:
|
||||
chosen = rand(candidates.high)
|
||||
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.commitTicks = TfilCommitTicks
|
||||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||||
m.blockedTile.active = false # clear after successful pick
|
||||
|
||||
# log-only: reversal test against the travel direction
|
||||
var rd = arctan2(m.commitTarget.y - ws.selfY, m.commitTarget.x - ws.selfX) *
|
||||
180.0 / PI - travelDeg
|
||||
while rd > 180.0: rd -= 360.0
|
||||
while rd < -180.0: rd += 360.0
|
||||
pickedThisTick = true
|
||||
pickedRev = abs(rd) > 90.0
|
||||
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:
|
||||
@@ -644,8 +834,26 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user