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:
@@ -23,6 +23,7 @@ import std/[os, strutils, algorithm, times, sets]
|
|||||||
import gun_harness/virtual_bullets
|
import gun_harness/virtual_bullets
|
||||||
import gun_harness/selector
|
import gun_harness/selector
|
||||||
import movements/ram_decision
|
import movements/ram_decision
|
||||||
|
import movements/the_floor_is_lava
|
||||||
import movements/the_floor_is_lava_ring
|
import movements/the_floor_is_lava_ring
|
||||||
import guns/tm_horizon
|
import guns/tm_horizon
|
||||||
import guns/pattern_matcher
|
import guns/pattern_matcher
|
||||||
@@ -243,6 +244,11 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
|||||||
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
|
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
|
||||||
emit("TR_TFIL_CORRIDOR_HEAT", $CorridorHeat, sourceOf("TR_TFIL_CORRIDOR_HEAT"))
|
emit("TR_TFIL_CORRIDOR_HEAT", $CorridorHeat, sourceOf("TR_TFIL_CORRIDOR_HEAT"))
|
||||||
emit("TR_TFIL_WALL_HOTNESS", $WallHotness, sourceOf("TR_TFIL_WALL_HOTNESS"))
|
emit("TR_TFIL_WALL_HOTNESS", $WallHotness, sourceOf("TR_TFIL_WALL_HOTNESS"))
|
||||||
|
emit("TR_TFIL_TILE_REPLAN", tileReplanName(TfilTileReplanMode),
|
||||||
|
sourceOf("TR_TFIL_TILE_REPLAN"))
|
||||||
|
emit("TR_TFIL_COMMIT_TICKS", $TfilCommitTicks, sourceOf("TR_TFIL_COMMIT_TICKS"))
|
||||||
|
emit("TR_TFIL_NO_REV", onOff(TfilNoRev), sourceOfPresence("TR_TFIL_NO_REV"))
|
||||||
|
emit("TR_TFIL_COMMIT_LOG", TfilCommitLogPath, sourceOfPresence("TR_TFIL_COMMIT_LOG"))
|
||||||
|
|
||||||
# ── ramming ───────────────────────────────────────────────────────────────
|
# ── ramming ───────────────────────────────────────────────────────────────
|
||||||
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
|
emit("TR_RAM_OPPORTUNITY", onOff(RamOppEnabled), sourceOf("TR_RAM_OPPORTUNITY"))
|
||||||
@@ -361,6 +367,8 @@ proc knownEnvNames*(): seq[string] =
|
|||||||
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
||||||
"TR_TFIL_RANGE_LO", "TR_TFIL_RANGE_HI", "TR_TFIL_RANGE_TEMP",
|
"TR_TFIL_RANGE_LO", "TR_TFIL_RANGE_HI", "TR_TFIL_RANGE_TEMP",
|
||||||
"TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
|
"TR_TFIL_RANGE_K", "TR_TFIL_CORRIDOR_HEAT", "TR_TFIL_WALL_HOTNESS",
|
||||||
|
"TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV",
|
||||||
|
"TR_TFIL_COMMIT_LOG",
|
||||||
# harness vars (read by the test framework, inherited by the bot, so they
|
# harness vars (read by the test framework, inherited by the bot, so they
|
||||||
# must NOT be reported as typos)
|
# must NOT be reported as typos)
|
||||||
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
"TR_SERVER_JAR", "TR_BATTLE_RUNNER", "TR_BATTLE_RUNNER_DIR",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import std/math
|
import std/math
|
||||||
import std/random
|
import std/random
|
||||||
|
import std/os
|
||||||
|
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
import movement_harness/movement_interface
|
import movement_harness/movement_interface
|
||||||
import robocode_tankroyale_botapi/graphics
|
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 CoolestLevels = 2 ## how many distinct lava values count as "cool"
|
||||||
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
|
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] =
|
proc bulletRadii(power: float): tuple[core, aura: float] =
|
||||||
let t = (power - 0.1) / 2.9
|
let t = (power - 0.1) / 2.9
|
||||||
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
|
let core = BulletCoreRadiusMin + t * (BulletCoreRadiusMax - BulletCoreRadiusMin)
|
||||||
@@ -70,6 +139,10 @@ type
|
|||||||
callCount: int ## computeMove call count; 0 = never called
|
callCount: int ## computeMove call count; 0 = never called
|
||||||
lastBotX, lastBotY: float ## bot position at last call; used to detect position jumps
|
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
|
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)
|
proc initTFIL*(): TFILModule = TFILModule(debugGraphics: false)
|
||||||
|
|
||||||
@@ -114,6 +187,44 @@ proc resetRound*(m: var TFILModule) =
|
|||||||
m.lastBotY = 0.0
|
m.lastBotY = 0.0
|
||||||
m.lastTileCol = 0
|
m.lastTileCol = 0
|
||||||
m.lastTileRow = 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) =
|
proc initGrid(m: var TFILModule, arenaWidth, arenaHeight: float) =
|
||||||
m.cols = int(arenaWidth / GridSize)
|
m.cols = int(arenaWidth / GridSize)
|
||||||
@@ -266,6 +377,19 @@ proc computeReachableHull(x0, y0, heading0, speed0,
|
|||||||
if cur == startIdx: break
|
if cur == startIdx: break
|
||||||
hull
|
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 =
|
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||||
if m.cols == 0:
|
if m.cols == 0:
|
||||||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||||
@@ -285,14 +409,30 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
|||||||
for ei in ws.enemies:
|
for ei in ws.enemies:
|
||||||
m.prevEnergySet(ei.id, ei.energy)
|
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):
|
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
|
||||||
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
case TfilTileReplanMode
|
||||||
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
of ttrSelf:
|
||||||
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
||||||
m.commitTicks = 0
|
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||||||
m.cachedHull = @[]
|
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
||||||
m.cachedInsideTiles = @[]
|
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
|
# Per-tick: advance existing bullets, detect new fires
|
||||||
m.advanceBullets(ws.selfX, ws.selfY)
|
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 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)
|
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)
|
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
|
||||||
type TileRef = tuple[col, row: int]
|
type TileRef = tuple[col, row: int]
|
||||||
if m.commitTicks == 0:
|
if m.commitTicks == 0:
|
||||||
@@ -563,7 +708,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
|||||||
# Commitment logic
|
# Commitment logic
|
||||||
if m.commitTicks > 0:
|
if m.commitTicks > 0:
|
||||||
# Only allow danger replan after MinCommitTicks have elapsed
|
# Only allow danger replan after MinCommitTicks have elapsed
|
||||||
let ticksElapsed = CommitTicks - m.commitTicks
|
let ticksElapsed = TfilCommitTicks - m.commitTicks
|
||||||
if ticksElapsed >= MinCommitTicks:
|
if ticksElapsed >= MinCommitTicks:
|
||||||
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||||||
let curLava = m.lavaAt(cc, cr)
|
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
|
# Mark committed tile blocked so we don't re-pick it
|
||||||
m.blockedTile = (col: cc, row: cr, active: true)
|
m.blockedTile = (col: cc, row: cr, active: true)
|
||||||
m.commitTicks = 0 # replan
|
m.commitTicks = 0 # replan
|
||||||
|
m.replanReason = rrDanger
|
||||||
else:
|
else:
|
||||||
dec m.commitTicks
|
dec m.commitTicks
|
||||||
|
if m.commitTicks == 0: m.replanReason = rrExpiry
|
||||||
else:
|
else:
|
||||||
dec m.commitTicks
|
dec m.commitTicks
|
||||||
|
if m.commitTicks == 0: m.replanReason = rrExpiry
|
||||||
|
|
||||||
if m.commitTicks == 0 and safeTiles.len > 0:
|
if m.commitTicks == 0 and safeTiles.len > 0:
|
||||||
# Filter out the blocked tile from candidates
|
# Filter out the blocked tile from candidates
|
||||||
@@ -584,14 +732,56 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
|||||||
continue
|
continue
|
||||||
candidates.add t
|
candidates.add t
|
||||||
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
|
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]
|
let ct = candidates[chosen]
|
||||||
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
|
m.commitTarget = (x: m.marginX + (ct.col.float + 0.5) * GridSize,
|
||||||
y: m.marginY + (ct.row.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.commitLava = m.lavaAt(ct.col, ct.row)
|
||||||
m.blockedTile.active = false # clear after successful pick
|
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:
|
if m.debugGraphics:
|
||||||
# Reachable hull perimeter (darker blue)
|
# Reachable hull perimeter (darker blue)
|
||||||
if m.cachedHull.len >= 3:
|
if m.cachedHull.len >= 3:
|
||||||
@@ -644,8 +834,26 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
|||||||
m.lastBotY = ws.selfY
|
m.lastBotY = ws.selfY
|
||||||
m.lastTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
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.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
|
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 ─────────────────────────────────────────────────────────────────
|
# ── Steering ─────────────────────────────────────────────────────────────────
|
||||||
let stepDx = m.commitTarget.x - ws.selfX
|
let stepDx = m.commitTarget.x - ws.selfX
|
||||||
let stepDy = m.commitTarget.y - ws.selfY
|
let stepDy = m.commitTarget.y - ws.selfY
|
||||||
|
|||||||
+20030
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
|||||||
|
## Guard test for the TFIL commitment knobs:
|
||||||
|
## TR_TFIL_TILE_REPLAN (self | off | enemy) default `self` = shipped
|
||||||
|
## TR_TFIL_COMMIT_TICKS (int) default 15 = shipped
|
||||||
|
## TR_TFIL_NO_REV (0/1) default 0 = shipped
|
||||||
|
## TR_TFIL_COMMIT_LOG (path) default off
|
||||||
|
##
|
||||||
|
## NO battle, NO Java, NO server. Run with:
|
||||||
|
## nim c -r --path:common_libs common_libs/tests/test_tfil_commit_env.nim
|
||||||
|
##
|
||||||
|
## The important check is #1: with EVERY knob unset the mover must reproduce,
|
||||||
|
## byte-for-byte, the move commands recorded from the PRE-CHANGE build. The
|
||||||
|
## golden `tests/fixtures/tfil_commit_default.golden` was generated from the
|
||||||
|
## shipped mover at commit f842ac0 by compiling THIS file with
|
||||||
|
## `-d:tfilGenGolden` against `git archive f842ac0` (see the golden's header).
|
||||||
|
## Regenerating it from the new code would defeat the check — only do that after
|
||||||
|
## a DELIBERATE change to the shipped defaults.
|
||||||
|
##
|
||||||
|
## The knob-dependent half of this file is wrapped in
|
||||||
|
## `when declared(loadTfilCommitEnv)` so the SAME file still compiles against
|
||||||
|
## the pre-change module and can regenerate the golden there. That is the whole
|
||||||
|
## point: the golden must come from the old code, not from the new one.
|
||||||
|
##
|
||||||
|
## The remaining checks prove the non-default arms actually do something (an
|
||||||
|
## A/B whose treatment did not apply is worthless) and that the soft
|
||||||
|
## no-reversal preference can never empty the candidate pool.
|
||||||
|
|
||||||
|
import std/[os, json, random, math, sequtils]
|
||||||
|
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
# Private-field access: include (do NOT import) the shipped mover.
|
||||||
|
include movements/the_floor_is_lava
|
||||||
|
|
||||||
|
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||||
|
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||||
|
const goldenPath = currentSourcePath().parentDir / "fixtures" /
|
||||||
|
"tfil_commit_default.golden"
|
||||||
|
const Seed = 20250923
|
||||||
|
const ArenaW = 800.0
|
||||||
|
const ArenaH = 600.0
|
||||||
|
|
||||||
|
var failures = 0
|
||||||
|
proc check(name: string, ok: bool) =
|
||||||
|
if ok: echo "PASS: ", name
|
||||||
|
else: echo "FAIL: ", name; inc failures
|
||||||
|
|
||||||
|
# ── fixture replay (works on BOTH the old and the new module) ────────────────
|
||||||
|
|
||||||
|
type
|
||||||
|
TickRec = object
|
||||||
|
call: int
|
||||||
|
spd, trn: float
|
||||||
|
tx, ty: float
|
||||||
|
ct: int
|
||||||
|
|
||||||
|
proc loadStates(): seq[WorldState] =
|
||||||
|
let path = repoRoot / "tools" / "fixtures" / fixtureRel
|
||||||
|
for rawLine in lines(path):
|
||||||
|
let line = rawLine.strip()
|
||||||
|
if line.len == 0: continue
|
||||||
|
let n = parseJson(line)
|
||||||
|
if n.hasKey("meta") or n.hasKey("end"): continue
|
||||||
|
let ex = n["ex"].getFloat()
|
||||||
|
let ey = n["ey"].getFloat()
|
||||||
|
result.add WorldState(
|
||||||
|
enemyX: ex, enemyY: ey,
|
||||||
|
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
|
||||||
|
enemyEnergy: n["ee"].getFloat(),
|
||||||
|
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
|
||||||
|
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
|
||||||
|
selfEnergy: n["se"].getFloat(),
|
||||||
|
arenaWidth: ArenaW, arenaHeight: ArenaH,
|
||||||
|
tick: n["tick"].getInt(),
|
||||||
|
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
|
||||||
|
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
|
||||||
|
energy: n["ee"].getFloat())])
|
||||||
|
|
||||||
|
proc loadRoundStarts(): seq[int] =
|
||||||
|
let side = repoRoot / "tools" / "fixtures" / "drussgt_meta" /
|
||||||
|
(fixtureRel & ".rounds.json")
|
||||||
|
if not fileExists(side): return
|
||||||
|
for r in parseFile(side)["rounds"]:
|
||||||
|
result.add r["startTick"].getInt()
|
||||||
|
|
||||||
|
proc replay(states: seq[WorldState], starts: seq[int]): seq[TickRec] =
|
||||||
|
## Drive the REAL computeMove over the recorded WorldState stream with a fixed
|
||||||
|
## RNG seed, exactly as `measure_tfil_heat_field.nim` does. Touches NO env
|
||||||
|
## knob, so it is identical on the old and the new module.
|
||||||
|
randomize(Seed)
|
||||||
|
var m = initTFIL()
|
||||||
|
for i in 0..<states.len:
|
||||||
|
if i == 0 or i in starts: m.resetRound()
|
||||||
|
let cmd = m.computeMove(states[i])
|
||||||
|
result.add TickRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate,
|
||||||
|
tx: m.commitTarget.x, ty: m.commitTarget.y,
|
||||||
|
ct: m.commitTicks)
|
||||||
|
|
||||||
|
proc recLine(r: TickRec): string =
|
||||||
|
$r.call & " " & $r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
|
||||||
|
|
||||||
|
# ── golden generation (OLD module only) ──────────────────────────────────────
|
||||||
|
|
||||||
|
when defined(tfilGenGolden):
|
||||||
|
block:
|
||||||
|
let recs = replay(loadStates(), loadRoundStarts())
|
||||||
|
var g = "# TFIL default-path parity golden.\n"
|
||||||
|
g.add "# Generated from commit f842ac0 (shipped mover, BEFORE the commit knobs)\n"
|
||||||
|
g.add "# by `nim c -r -d:tfilGenGolden` on this file against `git archive f842ac0`.\n"
|
||||||
|
g.add "# Format: call speed turnRate targetX targetY commitTicks\n"
|
||||||
|
for r in recs: g.add recLine(r) & "\n"
|
||||||
|
createDir(goldenPath.parentDir)
|
||||||
|
writeFile(goldenPath, g)
|
||||||
|
echo "wrote ", goldenPath, " (", recs.len, " ticks)"
|
||||||
|
quit(0)
|
||||||
|
|
||||||
|
# ── 1. default-path parity against the pre-change build ──────────────────────
|
||||||
|
|
||||||
|
proc testDefaultParity() =
|
||||||
|
doAssert fileExists(goldenPath), "missing golden: " & goldenPath
|
||||||
|
let recs = replay(loadStates(), loadRoundStarts())
|
||||||
|
var golden: seq[string]
|
||||||
|
for rawLine in lines(goldenPath):
|
||||||
|
if rawLine.startsWith("#"): continue
|
||||||
|
let line = rawLine.strip()
|
||||||
|
if line.len > 0: golden.add line
|
||||||
|
check "golden covers the whole fixture (>= 15000 ticks)", golden.len >= 15000
|
||||||
|
check "default replay covers the same number of ticks", recs.len == golden.len
|
||||||
|
var firstDiff = -1
|
||||||
|
for i in 0..<min(recs.len, golden.len):
|
||||||
|
if recLine(recs[i]) != golden[i]:
|
||||||
|
firstDiff = i
|
||||||
|
break
|
||||||
|
check "DEFAULT PATH IS BYTE-FOR-BYTE THE PRE-CHANGE MOVER (call/speed/turnRate/target/commitTicks)",
|
||||||
|
firstDiff < 0
|
||||||
|
if firstDiff >= 0:
|
||||||
|
echo " first divergence at tick index ", firstDiff, ": got [",
|
||||||
|
recLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
|
||||||
|
|
||||||
|
# ── everything below needs the NEW knob API ──────────────────────────────────
|
||||||
|
|
||||||
|
when declared(loadTfilCommitEnv):
|
||||||
|
|
||||||
|
type ArmStats = object
|
||||||
|
ticks: int
|
||||||
|
picks: int
|
||||||
|
byReason: array[TfilReplanReason, int]
|
||||||
|
intervals: seq[int]
|
||||||
|
reversals: int
|
||||||
|
meanSpeed: float
|
||||||
|
|
||||||
|
proc setArm(tileReplan: string, commitTicks: string, noRev: string) =
|
||||||
|
putEnv("TR_TFIL_TILE_REPLAN", tileReplan)
|
||||||
|
putEnv("TR_TFIL_COMMIT_TICKS", commitTicks)
|
||||||
|
putEnv("TR_TFIL_NO_REV", noRev)
|
||||||
|
loadTfilCommitEnv()
|
||||||
|
|
||||||
|
proc parseLog(path: string): seq[JsonNode] =
|
||||||
|
if not fileExists(path): return
|
||||||
|
for rawLine in lines(path):
|
||||||
|
let line = rawLine.strip()
|
||||||
|
if line.len > 0: result.add parseJson(line)
|
||||||
|
|
||||||
|
proc stats(log: seq[JsonNode]): ArmStats =
|
||||||
|
var spSum = 0.0
|
||||||
|
result.ticks = log.len
|
||||||
|
for o in log:
|
||||||
|
spSum += o["sp"].getFloat()
|
||||||
|
if o["pick"].getInt() == 1:
|
||||||
|
inc result.picks
|
||||||
|
result.intervals.add o["interval"].getInt()
|
||||||
|
if o["rev"].getInt() == 1: inc result.reversals
|
||||||
|
let r = o["reason"].getStr()
|
||||||
|
for rr in TfilReplanReason:
|
||||||
|
if reasonName(rr) == r: inc result.byReason[rr]
|
||||||
|
if log.len > 0: result.meanSpeed = spSum / log.len.float
|
||||||
|
|
||||||
|
proc meanInterval(s: ArmStats): float =
|
||||||
|
if s.intervals.len == 0: return 0.0
|
||||||
|
var t = 0
|
||||||
|
for v in s.intervals: t += v
|
||||||
|
t.float / s.intervals.len.float
|
||||||
|
|
||||||
|
proc reversalRate(s: ArmStats): float =
|
||||||
|
if s.picks == 0: return 0.0
|
||||||
|
100.0 * s.reversals.float / s.picks.float
|
||||||
|
|
||||||
|
proc replayLogged(tag: string): ArmStats =
|
||||||
|
let logPath = getTempDir() / ("tfil_commit_" & tag & ".jsonl")
|
||||||
|
removeFile(logPath)
|
||||||
|
closeTfilCommitLog()
|
||||||
|
putEnv("TR_TFIL_COMMIT_LOG", logPath)
|
||||||
|
loadTfilCommitEnv()
|
||||||
|
discard replay(loadStates(), loadRoundStarts())
|
||||||
|
closeTfilCommitLog()
|
||||||
|
putEnv("TR_TFIL_COMMIT_LOG", "")
|
||||||
|
loadTfilCommitEnv()
|
||||||
|
result = stats(parseLog(logPath))
|
||||||
|
|
||||||
|
# ── 2. knob parsing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testKnobParsing() =
|
||||||
|
setArm("off", "15", "0")
|
||||||
|
check "TR_TFIL_TILE_REPLAN=off -> ttrOff", TfilTileReplanMode == ttrOff
|
||||||
|
setArm("enemy", "15", "0")
|
||||||
|
check "TR_TFIL_TILE_REPLAN=enemy -> ttrEnemy", TfilTileReplanMode == ttrEnemy
|
||||||
|
setArm("self", "15", "0")
|
||||||
|
check "TR_TFIL_TILE_REPLAN=self -> ttrSelf", TfilTileReplanMode == ttrSelf
|
||||||
|
setArm("bogus", "15", "0")
|
||||||
|
check "unknown TR_TFIL_TILE_REPLAN falls back to the shipped ttrSelf",
|
||||||
|
TfilTileReplanMode == ttrSelf
|
||||||
|
setArm("self", "30", "0")
|
||||||
|
check "TR_TFIL_COMMIT_TICKS=30 is honoured", TfilCommitTicks == 30
|
||||||
|
setArm("self", "0", "0")
|
||||||
|
check "TR_TFIL_COMMIT_TICKS=0 is clamped to >= 1", TfilCommitTicks == 1
|
||||||
|
setArm("self", "15", "1")
|
||||||
|
check "TR_TFIL_NO_REV=1 -> TfilNoRev", TfilNoRev
|
||||||
|
setArm("self", "15", "0")
|
||||||
|
check "TR_TFIL_NO_REV unset -> false (shipped)", not TfilNoRev
|
||||||
|
setArm("self", "15", "0")
|
||||||
|
|
||||||
|
# ── 3. the treatments actually bite ────────────────────────────────────────
|
||||||
|
|
||||||
|
proc testArms() =
|
||||||
|
# arm A: control (shipped defaults)
|
||||||
|
setArm("self", "15", "0")
|
||||||
|
let a = replayLogged("a")
|
||||||
|
check "arm A (control): tile-change replans dominate the picks",
|
||||||
|
a.byReason[rrTileSelf].float / max(1, a.picks).float > 0.5
|
||||||
|
check "arm A (control): mean decision interval is ~5 ticks (commitment cancelled by motion)",
|
||||||
|
meanInterval(a) < 8.0
|
||||||
|
check "arm A (control): reversal-pick rate is high (> 20%)",
|
||||||
|
reversalRate(a) > 20.0
|
||||||
|
|
||||||
|
# arm B: honour the commitment
|
||||||
|
setArm("off", "15", "0")
|
||||||
|
let b = replayLogged("b")
|
||||||
|
check "arm B (TR_TFIL_TILE_REPLAN=off): ZERO tile-change replans",
|
||||||
|
b.byReason[rrTileSelf] == 0 and b.byReason[rrTileEnemy] == 0
|
||||||
|
check "arm B: commitment is only ended by expiry or the danger valve (plus the first pick of each round)",
|
||||||
|
b.byReason[rrExpiry] + b.byReason[rrDanger] + b.byReason[rrInit] == b.picks
|
||||||
|
check "arm B: the danger valve is a real but minor part of the replans (<10%)",
|
||||||
|
b.byReason[rrDanger].float / max(1, b.picks).float < 0.10
|
||||||
|
check "arm B: decision interval rises to the commit length (~15)",
|
||||||
|
meanInterval(b) > 12.0
|
||||||
|
check "arm B: FEWER decisions than arm A", b.picks < a.picks
|
||||||
|
|
||||||
|
# arm C: B + soft no-reversal
|
||||||
|
setArm("off", "15", "1")
|
||||||
|
let c = replayLogged("c")
|
||||||
|
check "arm C: reversal-pick rate drops vs arm B",
|
||||||
|
reversalRate(c) < reversalRate(b)
|
||||||
|
check "arm C: the candidate pool is never emptied (pick count within 2% of arm B)",
|
||||||
|
abs(c.picks - b.picks).float <= 0.02 * b.picks.float
|
||||||
|
check "arm C: no tile-change replans (same commitment as B)",
|
||||||
|
c.byReason[rrTileSelf] == 0
|
||||||
|
# The preference is a WEIGHT, never a filter: no candidate can get weight 0.
|
||||||
|
let wAllBack = noRevWeights(@[91.0, 120.0, 180.0, -179.0, -91.0], 0.0)
|
||||||
|
check "arm C: no-reversal weights are never 0 (pool cannot empty)",
|
||||||
|
wAllBack.allIt(it >= 1)
|
||||||
|
check "arm C: an all-backward pool falls back to uniform (every weight 1)",
|
||||||
|
wAllBack.allIt(it == 1)
|
||||||
|
let wMixed = noRevWeights(@[0.0, 45.0, 90.0, 90.1, 180.0], 0.0)
|
||||||
|
check "arm C: forward tiles (<=90 deg) are down-weighted by 3:1",
|
||||||
|
wMixed[0] == NoRevForwardWeight and wMixed[1] == NoRevForwardWeight and
|
||||||
|
wMixed[2] == NoRevForwardWeight and wMixed[3] == 1 and wMixed[4] == 1
|
||||||
|
|
||||||
|
# arm D: keyed to the enemy's tile
|
||||||
|
setArm("enemy", "15", "0")
|
||||||
|
let d = replayLogged("d")
|
||||||
|
check "arm D (TR_TFIL_TILE_REPLAN=enemy): some replans are keyed to the TARGET's tile",
|
||||||
|
d.byReason[rrTileEnemy] > 0
|
||||||
|
check "arm D: no self-tile cancels", d.byReason[rrTileSelf] == 0
|
||||||
|
check "arm D: the replan trigger differs from arm A (different pick count)",
|
||||||
|
d.picks != a.picks and d.byReason[rrTileEnemy] > d.byReason[rrTileSelf]
|
||||||
|
|
||||||
|
# arm E: commit duration
|
||||||
|
setArm("off", "30", "0")
|
||||||
|
let e = replayLogged("e")
|
||||||
|
check "arm E (TR_TFIL_COMMIT_TICKS=30): interval rises to ~30",
|
||||||
|
meanInterval(e) > 27.0
|
||||||
|
check "arm E: fewer decisions than arm B (15)", e.picks < b.picks
|
||||||
|
|
||||||
|
setArm("self", "15", "0")
|
||||||
|
|
||||||
|
echo "\n arm diagnostics (offline fixture replay):"
|
||||||
|
for (nm, s) in [("A control", a), ("B commit", b), ("C commit+noRev", c),
|
||||||
|
("D enemyTile", d), ("E commit30", e)]:
|
||||||
|
echo " ", nm.alignLeft(15), " ticks=", s.ticks, " picks=", s.picks,
|
||||||
|
" interval=", meanInterval(s).formatFloat(ffDecimal, 2),
|
||||||
|
" tileSelf=", s.byReason[rrTileSelf],
|
||||||
|
" tileEnemy=", s.byReason[rrTileEnemy],
|
||||||
|
" danger=", s.byReason[rrDanger],
|
||||||
|
" expiry=", s.byReason[rrExpiry],
|
||||||
|
" rev=", reversalRate(s).formatFloat(ffDecimal, 1), "%"
|
||||||
|
|
||||||
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testDefaultParity()
|
||||||
|
when declared(loadTfilCommitEnv):
|
||||||
|
testKnobParsing()
|
||||||
|
testArms()
|
||||||
|
|
||||||
|
if failures > 0:
|
||||||
|
echo "\n", failures, " check(s) FAILED"
|
||||||
|
quit(1)
|
||||||
|
echo "\nAll TFIL commit-env checks passed."
|
||||||
Reference in New Issue
Block a user