merge j165-ring-commit: port tfil's arrival commitment to tfil_ring (default off)
TR_TFIL_RING_COMMIT_ARRIVAL (default off) and TR_TFIL_RING_NOREV_SPEED (default 0.0), plus the tfil_ring replay ruler and its default-parity golden fixture. Offline parity is byte-for-byte over 20,026 ticks with the knobs unset. Never live-tested: the first live test of the_floor_is_lava_ring.nim under these knobs should be a real experiment, not a formality.
This commit is contained in:
@@ -104,6 +104,8 @@ TR_TFIL_RANGE_LO=100.0 # px; lower edge of the range band the ring mover prefe
|
|||||||
TR_TFIL_RANGE_HI=200.0 # px; upper edge of that band
|
TR_TFIL_RANGE_HI=200.0 # px; upper edge of that band
|
||||||
TR_TFIL_RANGE_TEMP=0.4 # sharpness of the ring mover's weighted random draw
|
TR_TFIL_RANGE_TEMP=0.4 # sharpness of the ring mover's weighted random draw
|
||||||
TR_TFIL_RANGE_K=60.0 # px; how fast the weight falls off outside the band
|
TR_TFIL_RANGE_K=60.0 # px; how fast the weight falls off outside the band
|
||||||
|
TR_TFIL_RING_COMMIT_ARRIVAL=off # tfil_ring only: on = hold the dodge tile until we are ON it (not a fixed dwell)
|
||||||
|
TR_TFIL_RING_NOREV_SPEED=0.0 # tfil_ring only; px/tick; below this, a mid-flight switch may not turn the bot around
|
||||||
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
|
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
|
||||||
TR_TFIL_CORRIDOR_TICKS=0.0 # corridor length in ticks: 0 = to the wall (shipped); N>0 = min(to wall, bullet speed * N)
|
TR_TFIL_CORRIDOR_TICKS=0.0 # corridor length in ticks: 0 = to the wall (shipped); N>0 = min(to wall, bullet speed * N)
|
||||||
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
|
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
|
||||||
|
|||||||
@@ -309,6 +309,12 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
|||||||
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI"))
|
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI"))
|
||||||
emit("TR_TFIL_RANGE_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
|
emit("TR_TFIL_RANGE_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
|
||||||
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
|
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
|
||||||
|
# j165: the ring fork's own arrival commitment (default off). RING-SPECIFIC
|
||||||
|
# names, so they can never be confused with the tfil mover's TR_TFIL_* pair.
|
||||||
|
emit("TR_TFIL_RING_COMMIT_ARRIVAL", onOff(TfilRingCommitArrival),
|
||||||
|
sourceOf("TR_TFIL_RING_COMMIT_ARRIVAL"))
|
||||||
|
emit("TR_TFIL_RING_NOREV_SPEED", $TfilRingNoRevSpeed,
|
||||||
|
sourceOf("TR_TFIL_RING_NOREV_SPEED"))
|
||||||
emit("TR_TFIL_CORRIDOR_HEAT", $the_floor_is_lava_ring.CorridorHeat,
|
emit("TR_TFIL_CORRIDOR_HEAT", $the_floor_is_lava_ring.CorridorHeat,
|
||||||
sourceOf("TR_TFIL_CORRIDOR_HEAT"))
|
sourceOf("TR_TFIL_CORRIDOR_HEAT"))
|
||||||
emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks,
|
emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks,
|
||||||
@@ -662,6 +668,7 @@ proc knownEnvNames*(): seq[string] =
|
|||||||
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
||||||
"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_RING_COMMIT_ARRIVAL", "TR_TFIL_RING_NOREV_SPEED",
|
||||||
"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_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS",
|
"TR_TFIL_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS",
|
||||||
"TR_TFIL_HOLD_WHEN_TRAPPED", "TR_TFIL_HOLD_MAX_TICKS",
|
"TR_TFIL_HOLD_WHEN_TRAPPED", "TR_TFIL_HOLD_MAX_TICKS",
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
|
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
|
||||||
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
|
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
|
||||||
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
|
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
|
||||||
|
## TR_TFIL_RING_COMMIT_ARRIVAL default off hold the committed tile until we
|
||||||
|
## are ON it (port of tfil's j144 fix)
|
||||||
|
## TR_TFIL_RING_NOREV_SPEED default 0.0 px/tick; below this a mid-flight
|
||||||
|
## switch may not turn the bot around
|
||||||
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
|
## `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.
|
## original mover did, so the same binary can serve as the control arm.
|
||||||
##
|
##
|
||||||
@@ -141,6 +145,36 @@ proc loadTfilRingFireEnv*() =
|
|||||||
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
|
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||||||
loadTfilRingFireEnv()
|
loadTfilRingFireEnv()
|
||||||
|
|
||||||
|
## ── j165: the ARRIVAL commitment, ported from `the_floor_is_lava.nim` ────────
|
||||||
|
## Same BEHAVIOUR as tfil's `TR_TFIL_COMMIT_ARRIVAL` / `TR_TFIL_NOREV_SPEED`,
|
||||||
|
## RING-SPECIFIC env names so the two forks never share a namespace by accident.
|
||||||
|
## Both default OFF, so the default path stays byte-for-byte today's ring
|
||||||
|
## (proved over the 20026-tick fixture replay in `test_tfil_commit_env.nim`).
|
||||||
|
## TR_TFIL_RING_COMMIT_ARRIVAL 0/1 hold the committed tile until we are
|
||||||
|
## ON it, instead of dropping the
|
||||||
|
## commitment on a tile crossing
|
||||||
|
## TR_TFIL_RING_NOREV_SPEED float while |speed| is below this, a
|
||||||
|
## mid-flight switch to the OPPOSITE
|
||||||
|
## side is refused (0 = off)
|
||||||
|
const
|
||||||
|
RingArriveRadius* = 18.0 ## "we are on the committed tile" — the same 18px
|
||||||
|
## radius `the_floor_is_lava.nim` uses
|
||||||
|
RingHullTicks = 50 ## the reachable-hull planning horizon (the literal
|
||||||
|
## 50 already passed to `computeReachableHull`
|
||||||
|
## below). Past it the committed target is no longer
|
||||||
|
## guaranteed reachable: the stall escape.
|
||||||
|
|
||||||
|
var
|
||||||
|
TfilRingCommitArrival* = false
|
||||||
|
TfilRingNoRevSpeed* = 0.0
|
||||||
|
|
||||||
|
proc loadTfilRingCommitEnv*() =
|
||||||
|
## Read the j165 knobs. Called once at module init; the guard test calls it
|
||||||
|
## again after `putEnv` so the non-default arms run in one process.
|
||||||
|
TfilRingCommitArrival = getEnvBool("TR_TFIL_RING_COMMIT_ARRIVAL", false)
|
||||||
|
TfilRingNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_RING_NOREV_SPEED", 0.0))
|
||||||
|
loadTfilRingCommitEnv()
|
||||||
|
|
||||||
const
|
const
|
||||||
DefaultRangeLo = 100.0
|
DefaultRangeLo = 100.0
|
||||||
DefaultRangeHi = 200.0
|
DefaultRangeHi = 200.0
|
||||||
@@ -240,6 +274,10 @@ type
|
|||||||
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
|
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
|
||||||
commitTicks: int ## ticks remaining on commitment
|
commitTicks: int ## ticks remaining on commitment
|
||||||
commitLava: float ## lava at commit time (for spike detection)
|
commitLava: float ## lava at commit time (for spike detection)
|
||||||
|
commitAge: int ## j165: ticks since the current target
|
||||||
|
## was picked (0 = just picked)
|
||||||
|
picks: int ## j165: picks made this round; > 0 means
|
||||||
|
## a switch would be MID-FLIGHT
|
||||||
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
|
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
|
||||||
cachedHull: seq[tuple[x, y: float]]
|
cachedHull: seq[tuple[x, y: float]]
|
||||||
cachedInsideTiles: seq[tuple[col, row: int]]
|
cachedInsideTiles: seq[tuple[col, row: int]]
|
||||||
@@ -283,6 +321,8 @@ proc resetRound*(m: var TFILRingModule) =
|
|||||||
m.bullets = @[]
|
m.bullets = @[]
|
||||||
m.fire.reset()
|
m.fire.reset()
|
||||||
m.commitTicks = 0
|
m.commitTicks = 0
|
||||||
|
m.commitAge = 0
|
||||||
|
m.picks = 0
|
||||||
m.cachedHull = @[]
|
m.cachedHull = @[]
|
||||||
m.cachedInsideTiles = @[]
|
m.cachedInsideTiles = @[]
|
||||||
m.blockedTile = (col: 0, row: 0, active: false)
|
m.blockedTile = (col: 0, row: 0, active: false)
|
||||||
@@ -462,6 +502,40 @@ proc computeReachableHull(x0, y0, heading0, speed0,
|
|||||||
if cur == startIdx: break
|
if cur == startIdx: break
|
||||||
hull
|
hull
|
||||||
|
|
||||||
|
proc ringTileOffTravel*(m: TFILRingModule, col, row: int,
|
||||||
|
sx, sy, travelDeg: float): float =
|
||||||
|
## Signed angle in degrees from the travel direction to the tile centre,
|
||||||
|
## folded into (-180, 180]. Verbatim from `the_floor_is_lava.nim`'s
|
||||||
|
## `tileOffTravel`; the ring's `ScoredTile` carries no `turnDeg`, so the
|
||||||
|
## no-reversal pool computes the offsets itself.
|
||||||
|
let tx = m.marginX + (col.float + 0.5) * GridSize
|
||||||
|
let ty = m.marginY + (row.float + 0.5) * GridSize
|
||||||
|
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
|
||||||
|
while result > 180.0: result -= 360.0
|
||||||
|
while result < -180.0: result += 360.0
|
||||||
|
|
||||||
|
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
|
||||||
|
## j165: which candidate tiles may a slow, mid-flight switch take? Verbatim
|
||||||
|
## from `the_floor_is_lava.nim`. `offs` are the signed angles (deg) from the
|
||||||
|
## travel direction to each candidate, `threshold` is the speed gate
|
||||||
|
## (px/tick). Returns the indices NOT more than 90 deg off — the bot does not
|
||||||
|
## have to turn around to reach them. If EVERY candidate is behind us the
|
||||||
|
## reversal is unavoidable, so the single LEAST-bad one is returned (a shallow
|
||||||
|
## turn, not a 180 deg flip): the result is NEVER empty, so the pick can never
|
||||||
|
## be starved. `threshold <= 0` = the knob is off and every candidate stays.
|
||||||
|
if offs.len == 0: return
|
||||||
|
if threshold <= 0.0:
|
||||||
|
for i in 0..<offs.len: result.add i
|
||||||
|
return
|
||||||
|
var keep: seq[int]
|
||||||
|
for i, a in offs:
|
||||||
|
if abs(a) <= 90.0: keep.add i
|
||||||
|
if keep.len > 0: return keep
|
||||||
|
var best = 0
|
||||||
|
for i, a in offs:
|
||||||
|
if abs(a) < abs(offs[best]): best = i
|
||||||
|
@[best]
|
||||||
|
|
||||||
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||||
if m.cols == 0:
|
if m.cols == 0:
|
||||||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||||
@@ -482,7 +556,13 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
|||||||
m.fire.prevEnergySet(ei.id, ei.energy)
|
m.fire.prevEnergySet(ei.id, ei.energy)
|
||||||
|
|
||||||
# Tile-change replan: catches gradual displacement that position threshold misses
|
# Tile-change replan: catches gradual displacement that position threshold misses
|
||||||
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
|
# j165: with TR_TFIL_RING_COMMIT_ARRIVAL the SELF-tile crossing is exactly the
|
||||||
|
# event that must NOT cancel a commitment: crossing a boundary is the very
|
||||||
|
# motion the commitment commands, and at GridSize 36 / speed 8 it fires every
|
||||||
|
# ~5 ticks — which is precisely this fork's CommitTicks. Under the shipped
|
||||||
|
# default (arrival off) this is the original block verbatim.
|
||||||
|
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
|
||||||
|
not TfilRingCommitArrival:
|
||||||
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
let 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)
|
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||||||
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
||||||
@@ -756,22 +836,50 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
|||||||
safeTiles.add blockedTiles[i]
|
safeTiles.add blockedTiles[i]
|
||||||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||||||
|
|
||||||
# Commitment logic
|
# Commitment logic. With every j165 knob at its default (both off) this is the
|
||||||
|
# original three-way test, unchanged. j165 adds one way OUT of a commitment
|
||||||
|
# that is NOT a tile crossing (the block above is skipped when armed) and turns
|
||||||
|
# the tick counter into a MINIMUM dwell: the target is held until we are
|
||||||
|
# actually standing on it.
|
||||||
|
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
|
||||||
|
RingArriveRadius * RingArriveRadius
|
||||||
if m.commitTicks > 0:
|
if m.commitTicks > 0:
|
||||||
# Only allow danger replan after MinCommitTicks have elapsed
|
inc m.commitAge
|
||||||
|
# Only allow a replan after MinCommitTicks have elapsed
|
||||||
let ticksElapsed = CommitTicks - m.commitTicks
|
let ticksElapsed = CommitTicks - 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)
|
||||||
|
var commitEnd = false
|
||||||
if curLava > m.commitLava + DangerReplanThreshold:
|
if curLava > m.commitLava + DangerReplanThreshold:
|
||||||
# Mark committed tile blocked so we don't re-pick it
|
# GENUINE DANGER: the committed tile got hot. Block it so we don't
|
||||||
|
# immediately re-pick it, and replan. This safety valve is deliberately
|
||||||
|
# independent of the arrival rule and is UNCHANGED by j165.
|
||||||
m.blockedTile = (col: cc, row: cr, active: true)
|
m.blockedTile = (col: cc, row: cr, active: true)
|
||||||
m.commitTicks = 0 # replan
|
commitEnd = true
|
||||||
else:
|
elif TfilRingCommitArrival:
|
||||||
|
if atTarget:
|
||||||
|
# Reached. Only now is a new target allowed.
|
||||||
|
commitEnd = true
|
||||||
|
elif m.commitAge >= RingHullTicks:
|
||||||
|
# Stall escape: past the planner's own reachability horizon the
|
||||||
|
# committed tile is no longer guaranteed reachable (rammed, boxed in).
|
||||||
|
commitEnd = true
|
||||||
|
if not commitEnd:
|
||||||
dec m.commitTicks
|
dec m.commitTicks
|
||||||
|
if m.commitTicks == 0 and TfilRingCommitArrival:
|
||||||
|
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
|
||||||
|
else:
|
||||||
|
m.commitTicks = 0
|
||||||
else:
|
else:
|
||||||
dec m.commitTicks
|
dec m.commitTicks
|
||||||
|
|
||||||
|
# j165: was the commitment we are about to replace still UNREACHED? A pick
|
||||||
|
# that replaces a target we had not yet got to is the owner's failure mode:
|
||||||
|
# the bot is still accelerating and the target flips under it. `picks == 0`
|
||||||
|
# means this is the first pick of the round, which is not a switch at all.
|
||||||
|
let midFlight = m.picks > 0 and not atTarget
|
||||||
|
|
||||||
if m.commitTicks == 0 and safeTiles.len > 0:
|
if m.commitTicks == 0 and safeTiles.len > 0:
|
||||||
# Filter out the blocked tile from candidates
|
# Filter out the blocked tile from candidates
|
||||||
var candidates: seq[ScoredTile]
|
var candidates: seq[ScoredTile]
|
||||||
@@ -780,6 +888,22 @@ proc computeMove*(m: var TFILRingModule, 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
|
||||||
|
# j165, no opposite-direction flip while still accelerating. Below the speed
|
||||||
|
# threshold the bot physically cannot complete a reversal before the bullet
|
||||||
|
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
|
||||||
|
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
|
||||||
|
# we are already standing on the committed tile (an arrival pick) the bot is
|
||||||
|
# free to go anywhere, and that is exactly the pick that must not be blocked.
|
||||||
|
if TfilRingNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilRingNoRevSpeed and midFlight:
|
||||||
|
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
|
||||||
|
else: ws.selfHeading
|
||||||
|
var offs: seq[float]
|
||||||
|
for t in candidates:
|
||||||
|
offs.add ringTileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
|
||||||
|
let keep = norevPool(offs, TfilRingNoRevSpeed)
|
||||||
|
var narrowed: seq[ScoredTile]
|
||||||
|
for i in keep: narrowed.add candidates[i]
|
||||||
|
candidates = narrowed
|
||||||
# Safety is a HARD constraint: the weighting below only re-orders the draw
|
# 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.
|
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
|
||||||
# It can never select a tile the unweighted code would have rejected
|
# It can never select a tile the unweighted code would have rejected
|
||||||
@@ -801,6 +925,8 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
|||||||
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
||||||
m.commitTicks = CommitTicks
|
m.commitTicks = CommitTicks
|
||||||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||||||
|
m.commitAge = 0
|
||||||
|
inc m.picks
|
||||||
m.blockedTile.active = false # clear after successful pick
|
m.blockedTile.active = false # clear after successful pick
|
||||||
|
|
||||||
# One concise log line on a range-class or band change (never per-tick).
|
# One concise log line on a range-class or band change (never per-tick).
|
||||||
|
|||||||
+20031
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,10 @@ import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
|||||||
import gun_harness/gun_interface
|
import gun_harness/gun_interface
|
||||||
# Private-field access: include (do NOT import) the shipped mover.
|
# Private-field access: include (do NOT import) the shipped mover.
|
||||||
include movements/the_floor_is_lava
|
include movements/the_floor_is_lava
|
||||||
|
# j165: the TFIL-RING fork's arrival commitment. `tfil_ring_replay` includes
|
||||||
|
# the ring mover (for its PRIVATE commitTarget/commitTicks) and re-exports it,
|
||||||
|
# so this is the only ring import the guard needs.
|
||||||
|
import tfil_ring_replay
|
||||||
|
|
||||||
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||||
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||||
@@ -1479,6 +1483,130 @@ proc testJ154() =
|
|||||||
check "j154: clearing the knob restores today's behaviour exactly",
|
check "j154: clearing the knob restores today's behaviour exactly",
|
||||||
TfilHoldMaxTicks == 0
|
TfilHoldMaxTicks == 0
|
||||||
|
|
||||||
|
# ── j165: the TFIL-RING arrival commitment (TR_TFIL_RING_*, default OFF) ─────
|
||||||
|
# The same two mechanisms ported from `the_floor_is_lava.nim` (j144) onto
|
||||||
|
# RING-SPECIFIC env names, so the two forks never share a namespace by
|
||||||
|
# accident. What must hold, and nothing more:
|
||||||
|
# 1. DEFAULT PARITY: with both knobs unset the ring mover is byte-for-byte
|
||||||
|
# the PRE-CHANGE ring mover over the whole fixture replay.
|
||||||
|
# 2. the arrival commitment ENGAGES: the committed target is actually
|
||||||
|
# REACHED far more often, and there are strictly fewer mid-flight
|
||||||
|
# re-targets than the shipped fixed 5-tick dwell.
|
||||||
|
# 3. the no-reversal pool is SPEED-GATED: the stream may only diverge from
|
||||||
|
# the unarmed build on a tick whose |selfSpeed| is below the gate, and the
|
||||||
|
# pool itself can never be emptied.
|
||||||
|
when declared(TfilRingCommitArrival):
|
||||||
|
|
||||||
|
proc ringRunStats(): tuple[picks, reached: int] =
|
||||||
|
## Count picks over the replay and how many of them REPLACED a target the bot
|
||||||
|
## had actually stood on (< 18px, the same arrival radius the mover uses).
|
||||||
|
## Under a fixed dwell this is rare: the target is replaced mid-flight.
|
||||||
|
randomize(Seed)
|
||||||
|
var m = initTFILRing()
|
||||||
|
let states = loadStates()
|
||||||
|
let starts = loadRoundStarts()
|
||||||
|
var prev = (x: 0.0, y: 0.0)
|
||||||
|
var hadPick = false
|
||||||
|
for i in 0..<states.len:
|
||||||
|
if i == 0 or i in starts:
|
||||||
|
m.resetRound()
|
||||||
|
hadPick = false
|
||||||
|
let ws = states[i]
|
||||||
|
let before = ringPicks(m)
|
||||||
|
discard m.computeMove(ws)
|
||||||
|
if ringPicks(m) != before:
|
||||||
|
inc result.picks
|
||||||
|
if hadPick and
|
||||||
|
sqrt((ws.selfX - prev.x)^2 + (ws.selfY - prev.y)^2) < RingArriveRadius:
|
||||||
|
inc result.reached
|
||||||
|
prev = ringTarget(m)
|
||||||
|
hadPick = true
|
||||||
|
|
||||||
|
proc testJ165() =
|
||||||
|
doAssert fileExists(RingGoldenPath), "missing golden: " & RingGoldenPath
|
||||||
|
let recs = replayRing() # both knobs unset; the shipped fire detector, as
|
||||||
|
# the golden was generated
|
||||||
|
var golden: seq[string]
|
||||||
|
for rawLine in lines(RingGoldenPath):
|
||||||
|
if rawLine.startsWith("#"): continue
|
||||||
|
let line = rawLine.strip()
|
||||||
|
if line.len > 0: golden.add line
|
||||||
|
check "j165: ring golden covers the whole fixture (>= 15000 ticks)",
|
||||||
|
golden.len >= 15000
|
||||||
|
check "j165: the unset replay covers the same number of ticks",
|
||||||
|
recs.len == golden.len
|
||||||
|
var firstDiff = -1
|
||||||
|
for i in 0..<min(recs.len, golden.len):
|
||||||
|
if ringRecLine(recs[i]) != golden[i]:
|
||||||
|
firstDiff = i
|
||||||
|
break
|
||||||
|
check "j165: BOTH KNOBS UNSET IS BYTE-FOR-BYTE THE PRE-CHANGE RING MOVER " &
|
||||||
|
"(speed/turnRate/target/commitTicks) over " & $recs.len & " ticks",
|
||||||
|
firstDiff < 0
|
||||||
|
if firstDiff >= 0:
|
||||||
|
echo " first divergence at tick index ", firstDiff, ": got [",
|
||||||
|
ringRecLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
|
||||||
|
|
||||||
|
delEnv("TR_TFIL_RING_COMMIT_ARRIVAL")
|
||||||
|
delEnv("TR_TFIL_RING_NOREV_SPEED")
|
||||||
|
loadTfilRingCommitEnv()
|
||||||
|
check "j165: both knobs DEFAULT OFF with the env deleted",
|
||||||
|
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
|
||||||
|
putEnv("TR_TFIL_COMMIT_ARRIVAL", "1") # tfil's names must NOT leak across
|
||||||
|
putEnv("TR_TFIL_NOREV_SPEED", "9")
|
||||||
|
loadTfilRingCommitEnv()
|
||||||
|
check "j165: the env names are RING-SPECIFIC — tfil's " &
|
||||||
|
"TR_TFIL_COMMIT_ARRIVAL / TR_TFIL_NOREV_SPEED leave ring untouched",
|
||||||
|
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
|
||||||
|
delEnv("TR_TFIL_COMMIT_ARRIVAL")
|
||||||
|
delEnv("TR_TFIL_NOREV_SPEED")
|
||||||
|
|
||||||
|
# 2. the arrival commitment engages
|
||||||
|
let off = ringRunStats()
|
||||||
|
TfilRingCommitArrival = true
|
||||||
|
let on = ringRunStats()
|
||||||
|
TfilRingCommitArrival = false
|
||||||
|
check "j165: ARRIVAL ENGAGES — a committed target is actually REACHED on " &
|
||||||
|
$on.reached & "/" & $on.picks & " picks, vs " & $off.reached & "/" &
|
||||||
|
$off.picks & " on the shipped fixed 5-tick dwell",
|
||||||
|
on.picks > 0 and (on.reached.float / on.picks.float) >
|
||||||
|
(off.reached.float / off.picks.float)
|
||||||
|
check "j165: ... and it holds instead of re-targeting mid-flight: " &
|
||||||
|
$on.picks & " picks vs " & $off.picks & " on the same replay",
|
||||||
|
on.picks < off.picks
|
||||||
|
|
||||||
|
# 3. the no-reversal pool is speed-gated, and can never be emptied
|
||||||
|
check "j165: norevPool with the gate off returns EVERY candidate",
|
||||||
|
norevPool(@[10.0, 120.0, -170.0], 0.0) == @[0, 1, 2]
|
||||||
|
check "j165: norevPool armed keeps only the non-reversing candidates",
|
||||||
|
norevPool(@[10.0, 120.0, -170.0], 4.0) == @[0]
|
||||||
|
check "j165: norevPool never empties — all-behind falls back to the least bad",
|
||||||
|
norevPool(@[170.0, 150.0, 179.0], 4.0) == @[1] and
|
||||||
|
norevPool(@[170.0, 179.0], 4.0).len > 0
|
||||||
|
# Engine gate: the streams can only DIVERGE at a gated pick. (Divergence
|
||||||
|
# then persists for many ticks — a different target steers differently — so
|
||||||
|
# "every diverging tick is slow" is the wrong claim; "the FIRST divergence
|
||||||
|
# is a slow-tick pick" is the right one.)
|
||||||
|
TfilRingNoRevSpeed = 4.0
|
||||||
|
let armed = replayRing()
|
||||||
|
TfilRingNoRevSpeed = 0.0
|
||||||
|
var nDiv = 0
|
||||||
|
var firstArmed = -1
|
||||||
|
for i in 0..<min(recs.len, armed.len):
|
||||||
|
if ringRecLine(recs[i]) != ringRecLine(armed[i]):
|
||||||
|
inc nDiv
|
||||||
|
if firstArmed < 0: firstArmed = i
|
||||||
|
let states = loadRingStates()
|
||||||
|
check "j165: the no-reversal treatment APPLIES (gate 4.0 changes " & $nDiv &
|
||||||
|
" of " & $recs.len & " ticks — an A/B whose treatment never fires is worthless)",
|
||||||
|
nDiv > 0
|
||||||
|
check "j165: ... and it is SPEED-GATED: the first divergence (tick " &
|
||||||
|
$firstArmed & ") is a PICK made at |selfSpeed| = " &
|
||||||
|
$(if firstArmed >= 0: abs(states[firstArmed].selfSpeed) else: -1.0) &
|
||||||
|
" < 4.0",
|
||||||
|
firstArmed >= 0 and armed[firstArmed].picked and
|
||||||
|
abs(states[firstArmed].selfSpeed) < 4.0
|
||||||
|
|
||||||
# ── driver ───────────────────────────────────────────────────────────────────
|
# ── driver ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
testDefaultParity()
|
testDefaultParity()
|
||||||
@@ -1494,6 +1622,8 @@ when declared(loadTfilCommitEnv):
|
|||||||
testJ150()
|
testJ150()
|
||||||
testJ154()
|
testJ154()
|
||||||
testJ153()
|
testJ153()
|
||||||
|
when declared(TfilRingCommitArrival):
|
||||||
|
testJ165()
|
||||||
|
|
||||||
if failures > 0:
|
if failures > 0:
|
||||||
echo "\n", failures, " check(s) FAILED"
|
echo "\n", failures, " check(s) FAILED"
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
## Fixture replay for the TFIL-RING mover (`movements/the_floor_is_lava_ring.nim`).
|
||||||
|
##
|
||||||
|
## It `include`s the mover (not `import`s it) so the replay can read the
|
||||||
|
## private `commitTarget` / `commitTicks` — the same reason
|
||||||
|
## `test_tfil_commit_env.nim` includes `the_floor_is_lava.nim`. Living in its
|
||||||
|
## OWN module keeps those privates in this module's scope, so a file that
|
||||||
|
## includes BOTH movers still compiles (there is no name clash between them:
|
||||||
|
## this one only sees ring's).
|
||||||
|
##
|
||||||
|
## `--path:common_libs` relative to the repo root.
|
||||||
|
##
|
||||||
|
## GOLDEN GENERATION (j165 default parity). Compile this file with the golden
|
||||||
|
## flag against the PRE-CHANGE ring, e.g. from a `git show HEAD:...` tree:
|
||||||
|
##
|
||||||
|
## TFIL_RING_GOLDEN_OUT=<path> \
|
||||||
|
## nim c -r --path:. -d:tfilRingGenGolden common_libs/tests/tfil_ring_replay.nim
|
||||||
|
##
|
||||||
|
## Nothing here references a j165 symbol, so the SAME file generates the golden
|
||||||
|
## on the pre-change mover and checks it on the post-change one. Regenerating
|
||||||
|
## the golden from the new code would defeat the check — only do that after a
|
||||||
|
## DELIBERATE change to the ring defaults.
|
||||||
|
|
||||||
|
import std/[os, json, random, math]
|
||||||
|
import std/strutils except fromHex
|
||||||
|
import gun_harness/gun_interface
|
||||||
|
include movements/the_floor_is_lava_ring
|
||||||
|
|
||||||
|
const
|
||||||
|
Seed = 20250923 ## same seed as the tfil replay: comparable arms
|
||||||
|
ArenaW = 800.0
|
||||||
|
ArenaH = 600.0
|
||||||
|
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||||
|
|
||||||
|
const RingGoldenPath* = currentSourcePath().parentDir / "fixtures" /
|
||||||
|
"tfil_ring_commit_default.golden"
|
||||||
|
|
||||||
|
type RingTickRec* = object
|
||||||
|
spd, trn: float ## the emitted MoveCommand
|
||||||
|
tx, ty: float ## where we are steering to
|
||||||
|
ct: int ## ticks left on the commitment
|
||||||
|
picked*: bool ## this tick made a NEW pick (not in the golden)
|
||||||
|
|
||||||
|
# Thin accessors for the mover's PRIVATE per-round state. The guard needs them
|
||||||
|
# to measure what the commitment did; the mover's own API stays unchanged.
|
||||||
|
proc ringPicks*(m: TFILRingModule): int = m.picks
|
||||||
|
proc ringTarget*(m: TFILRingModule): tuple[x, y: float] = m.commitTarget
|
||||||
|
|
||||||
|
proc ringRecLine*(r: RingTickRec): string =
|
||||||
|
$r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
|
||||||
|
|
||||||
|
proc loadRingStates*(): seq[WorldState] =
|
||||||
|
let path = currentSourcePath().parentDir.parentDir.parentDir / "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 loadRingStarts*(): seq[int] =
|
||||||
|
let side = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
|
||||||
|
"fixtures" / "drussgt_meta" / (fixtureRel & ".rounds.json")
|
||||||
|
if not fileExists(side): return
|
||||||
|
for r in parseFile(side)["rounds"]:
|
||||||
|
result.add r["startTick"].getInt()
|
||||||
|
|
||||||
|
proc replayRing*(): seq[RingTickRec] =
|
||||||
|
## Drive the REAL ring `computeMove` over the recorded WorldState stream with a
|
||||||
|
## fixed seed, touching no env knob. With every j165 knob unset this is the
|
||||||
|
## pre-change code path exactly.
|
||||||
|
randomize(Seed)
|
||||||
|
var m = initTFILRing()
|
||||||
|
let states = loadRingStates()
|
||||||
|
let starts = loadRingStarts()
|
||||||
|
for i in 0..<states.len:
|
||||||
|
if i == 0 or i in starts: m.resetRound()
|
||||||
|
let before = m.picks
|
||||||
|
let cmd = m.computeMove(states[i])
|
||||||
|
result.add RingTickRec(spd: cmd.speed, trn: cmd.turnRate,
|
||||||
|
tx: m.commitTarget.x, ty: m.commitTarget.y,
|
||||||
|
ct: m.commitTicks, picked: m.picks != before)
|
||||||
|
|
||||||
|
when isMainModule and defined(tfilRingGenGolden):
|
||||||
|
block:
|
||||||
|
let recs = replayRing()
|
||||||
|
let outPath = getEnv("TFIL_RING_GOLDEN_OUT", RingGoldenPath)
|
||||||
|
var g = "# TFIL-RING default-path parity golden (j165).\n"
|
||||||
|
g.add "# Generated from the PRE-CHANGE ring mover (`git show HEAD:...`) with\n"
|
||||||
|
g.add "# every j165 knob UNSET, over the whole\n"
|
||||||
|
g.add "# tools/fixtures/tr_drussgt_vs_modularbot.jsonl replay.\n"
|
||||||
|
g.add "# Format: speed turnRate targetX targetY commitTicks\n"
|
||||||
|
for r in recs: g.add ringRecLine(r) & "\n"
|
||||||
|
createDir(outPath.parentDir)
|
||||||
|
writeFile(outPath, g)
|
||||||
|
echo "wrote ", outPath, " (", recs.len, " ticks)"
|
||||||
Reference in New Issue
Block a user