From 38fbc6ecd1f4171e5bdfb3a91fdc03902c3f6e97 Mon Sep 17 00:00:00 2001 From: Davide Cappellini Date: Sun, 27 Sep 2026 10:22:24 +0200 Subject: [PATCH] j152 (default-off): geometry shapes the tile DRAW (TR_TFIL_GEO_MODE/TAU) + the offline sweep with the diversity cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner: choose the tile pool not only from the heat point but from the geometric position too. Heat stays the hard filter; the draw over the survivors is re-weighted by turn and/or distance. Three weighting forms (soft softmax / top-K third / rejection band), one env name carrying both axes. Default = off = today's uniform draw, byte-for-byte (golden parity). WHAT DIFFERS FROM j9 (TR_TFIL_TURN_BIAS, a live null): that was a tiebreak weight among the non-empty safe set only. This runs on the WHOLE pool the draw already runs on, including the 2 promoted least-hot tiles the ~65% forced picks choose from. OFFLINE (8 fixtures x 3 seeds, no java): headline 'tile actually reached at tta' 4.5% -> 16.9% at TR_TFIL_GEO_MODE=both-soft TR_TFIL_GEO_TAU=45, with no diversity collapse (distinct tiles 220 -> 219, normalised entropy 0.87 -> 0.86, top-tile share 7.0% -> 8.6%). perpE — the perpendicular rate in the FORCED population — does not move in ANY arm: that pool is 2 tiles ranked by heat alone and the only lever is a coin flip. Also registers j150's TR_TFIL_DIAG / TR_TFIL_DANGER_THRESHOLD in env_report + knownEnvNames + .env.example (test_env_report was red) and documents DANGER_THRESHOLD as quantisation-limited: heat comes in 5s, so the effective steps are 10/15/20 and 10-14 admits zero extra tiles. No battle, no server, no A/B run. --- ModularBot_garage/.env.example | 5 + ModularBot_garage/src/env_report.nim | 13 + common_libs/movements/the_floor_is_lava.nim | 240 ++++++++++- .../tests/measure_tfil_pick_defects.nim | 157 +++++-- common_libs/tests/test_tfil_commit_env.nim | 406 +++++++++++++++++- 5 files changed, 793 insertions(+), 28 deletions(-) diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index 8c87c8a..e52fd17 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -113,6 +113,11 @@ TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning TR_TFIL_ARRIVE_TICKS=0.0 # hard bound: never pick a tile farther than this (ticks at 8px/tick); 0 = off = today's draw +TR_TFIL_HOLD_WHEN_TRAPPED=off # on = when NO safe tile exists, hold position one tick instead of taking the 2 least-hot blocked tiles; off = today's fallback +TR_TFIL_DANGER_THRESHOLD=10.0 # hard heat filter on the path; lava is quantised to 5, so the effective steps are 10/15/20 and 10-14 admits exactly what 10 does +TR_TFIL_DIAG=off # on = fill the per-pick picker loss histogram (TfilLoss*); observability only +TR_TFIL_GEO_MODE=off # off | turn | dist | both, each with a `-soft` (default) / `-topk` / `-rej` suffix; shapes the DRAW over the heat-filtered pool +TR_TFIL_GEO_TAU=0.0 # deg; the geometric cost scale. 0 = off = today's uniform draw TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell) diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index b1d7a37..c801388 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -315,6 +315,8 @@ proc printEffectiveValues(ctx: EnvReportContext) = sourceOf("TR_TFIL_CORRIDOR_TICKS")) emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks, sourceOf("TR_TFIL_ARRIVE_TICKS")) + emit("TR_TFIL_HOLD_WHEN_TRAPPED", onOff(the_floor_is_lava.TfilHoldWhenTrapped), + sourceOfPresence("TR_TFIL_HOLD_WHEN_TRAPPED")) emit("TR_TFIL_WALL_HOTNESS", $the_floor_is_lava_ring.WallHotness, sourceOf("TR_TFIL_WALL_HOTNESS")) emit("TR_TFIL_WALL_RADIANCE", $the_floor_is_lava.WallRadiance, @@ -333,6 +335,14 @@ proc printEffectiveValues(ctx: EnvReportContext) = emit("TR_TFIL_COMMIT_MARGIN", $TfilCommitMargin, sourceOf("TR_TFIL_COMMIT_MARGIN")) emit("TR_TFIL_NOREV_SPEED", $TfilNoRevSpeed, sourceOf("TR_TFIL_NOREV_SPEED")) + emit("TR_TFIL_DANGER_THRESHOLD", $the_floor_is_lava.TfilDangerThreshold, + sourceOf("TR_TFIL_DANGER_THRESHOLD")) + emit("TR_TFIL_DIAG", onOff(the_floor_is_lava.TfilDiag), + sourceOfPresence("TR_TFIL_DIAG")) + emit("TR_TFIL_GEO_MODE", $the_floor_is_lava.TfilGeoMode, + sourceOf("TR_TFIL_GEO_MODE")) + emit("TR_TFIL_GEO_TAU", $the_floor_is_lava.TfilGeoTau, + sourceOf("TR_TFIL_GEO_TAU")) emit("TR_TFIL_TURN_BIAS", $TfilTurnBias, sourceOf("TR_TFIL_TURN_BIAS")) emit("TR_TFIL_TURN_REF_DEG", $TfilTurnRefDeg, sourceOf("TR_TFIL_TURN_REF_DEG")) # time-indexed bullet heat (default off = shipped flat model) @@ -652,6 +662,9 @@ proc knownEnvNames*(): seq[string] = "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_CORRIDOR_TICKS", "TR_TFIL_ARRIVE_TICKS", + "TR_TFIL_HOLD_WHEN_TRAPPED", + "TR_TFIL_DANGER_THRESHOLD", "TR_TFIL_DIAG", + "TR_TFIL_GEO_MODE", "TR_TFIL_GEO_TAU", "TR_TFIL_WALL_RADIANCE", "TR_TFIL_BULLET_CORE", "TR_TFIL_BULLET_AURA", "TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV", "TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN", diff --git a/common_libs/movements/the_floor_is_lava.nim b/common_libs/movements/the_floor_is_lava.nim index 092ba02..eb2573e 100644 --- a/common_libs/movements/the_floor_is_lava.nim +++ b/common_libs/movements/the_floor_is_lava.nim @@ -70,6 +70,15 @@ var const CommitTicks = 15 ## ticks to commit to a dodge point const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed +## j154: the DERIVED hold budget's panic horizon. Server `rules/math.kt`: +## calcGunHeat(p) = 1 + p/5, coolDown 0.1/tick, and a gun may only fire at +## heat == 0 (`core/GunEngine.kt:36`), so two MAX-power shots are 1.6/0.1 = 16 +## ticks apart and a 3.0-power bullet is `calcBulletDamage(3) = 16`. 16 ticks +## is therefore the window in which the enemy can land AT MOST its next two +## shots (32 damage) on us — the whole exposure a hold can possibly buy. It is +## also the FIRST window that admits the enemy's second shot at all, so a +## hold shorter than this can never be surprised by a third bullet. +const HoldPanicTicks = 16.0 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 @@ -95,6 +104,33 @@ type rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry, rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin +type + TfilGeoDim* = enum ## WHAT geometry the draw is shaped by + gdoOff = "off", gdoTurn = "turn", gdoDist = "dist", gdoBoth = "both" + TfilGeoShape* = enum ## HOW the shape is turned into a draw + gfSoft = "soft", gfTopK = "topk", gfRej = "rej" + +proc parseGeo*(s: string): tuple[dim: TfilGeoDim, form: TfilGeoShape] = + ## `"turn"`, `"dist"`, `"both"` | any of those + `"-soft"` (default) | + ## `"-topk"` | `"-rej"`. Anything unrecognised, and `"off"`, is OFF = today's + ## uniform draw. One env name, two axes: a `both-soft`/`both-topk` grid would + ## need three names for the same three dials. + var a = s.strip().toLowerAscii() + var form = gfSoft + let dash = a.rfind('-') + if dash > 0: + case a[dash + 1 .. ^1] + of "topk": form = gfTopK; a = a[0 ..< dash] + of "rej", "rejection": form = gfRej; a = a[0 ..< dash] + of "soft": a = a[0 ..< dash] # the default form, spelled out + else: discard + result.form = form + result.dim = case a + of "turn": gdoTurn + of "dist", "distance": gdoDist + of "both": gdoBoth + else: gdoOff + proc tileReplanName*(m: TfilTileReplan): string = case m of ttrSelf: "self" @@ -163,11 +199,47 @@ var ## the DEFAULT is today's `10.0`, so the default path is bit-identical. ## TR_TFIL_DANGER_THRESHOLD default 10.0 TfilDangerThreshold* = DefaultDangerThreshold + ## j152: `TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` — GEOMETRY shapes the DRAW. + ## Heat still gates the pool with the same hard filter; this only re-weights + ## the survivors by how far the tile sits from where we are already going. + ## TR_TFIL_GEO_MODE off | turn | dist | both [+ `-soft` | `-topk` | `-rej`] + ## TR_TFIL_GEO_TAU deg, 0 = off (= today's uniform draw, exactly) + ## WHAT IS DIFFERENT FROM j9 (`TR_TFIL_TURN_BIAS`, a live null): that was a + ## tiebreak WEIGHT applied only among the non-empty safe set. This runs on the + ## WHOLE pool the draw already runs on, so it also shapes the 2 promoted + ## least-hot tiles the ~65% forced (safePre < 2) picks choose from. + TfilGeoMode*: TfilGeoDim = gdoOff + TfilGeoForm*: TfilGeoShape = gfSoft + TfilGeoTau*: float = 0.0 ## j151: `TR_TFIL_ARRIVE_TICKS` — refuse a candidate we cannot REACH inside ## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a ## preference; empty pool => today's full pool, so it can never starve the ## draw. 0 (default) = off = byte-for-byte today. TfilArriveTicks*: float = 0.0 + ## j153: `TR_TFIL_HOLD_WHEN_TRAPPED` — when the SAFE set is EMPTY (zero tiles + ## with `pathMaxHeat <= PathDangerThreshold` inside the reachable hull), STOP + ## for this tick instead of promoting the 2 least-hot blocked tiles. The + ## owner's rule: "if no tile is found to go, to not choose the less dangerous, + ## but to stay still! the next tick probably the situation already changed". + ## ONE tick only, never latched: the hold is taken at the pick site, and the + ## pick site only runs when `commitTicks == 0`, so the very next tick + ## re-evaluates the field from scratch. That is why there is no max-hold knob: + ## a counter can only add a way to get stuck. + ## Default false = byte-for-byte today's promote-the-2 behaviour. + TfilHoldWhenTrapped*: bool = false + ## j154: `TR_TFIL_HOLD_MAX_TICKS` — the BOUNDED version of the j153 one-tick + ## hold. While the safe tile set stays EMPTY the mover holds position for at + ## most this many ticks (the enemy's own rate of fire bounds what waiting can + ## buy: see `HoldPanicTicks`). Rules, all four of them load-bearing: + ## * a safe tile exists -> release on the SAME tick, always + ## * a tracked bullet reaches us within `min(N, 16)` ticks -> PANIC RELEASE, + ## the hold is overridden and the normal promote-the-2 fallback resumes + ## * the counter resets when a safe tile is taken, so the bound is per + ## empty-streak, not per round + ## * the gun is untouched: `computeMove` never fires, so a held tick still + ## fires exactly as every other tick (verified in `test_tfil_commit_env`) + ## Default 0 = OFF = byte-for-byte today's behaviour, j153 included. + TfilHoldMaxTicks*: int = 0 ## j150: `TR_TFIL_DIAG` — fill `TfilLoss*` with the per-pick LOSS HISTOGRAM ## (how many tiles die at each picker stage). Pure counters, off by default. TfilDiag*: bool = false @@ -228,12 +300,18 @@ proc loadTfilCommitEnv*() = TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD", DefaultDangerThreshold)) TfilDiag = getEnvBool("TR_TFIL_DIAG", false) + let (gd, gf) = parseGeo(getEnv("TR_TFIL_GEO_MODE", "off")) + TfilGeoMode = gd + TfilGeoForm = gf + TfilGeoTau = max(0.0, getEnvFloat("TR_TFIL_GEO_TAU", 0.0)) # j151: hard arrival bound. The draw is UNIFORM over every safe tile inside the # 50-tick reachable hull, so a tile 47 ticks away had the same 1-in-52 chance # as the adjacent one, while the target is only HELD for CommitTicks=15. The # offline ruler (measure_tfil_pick_defects) measured 65% of picks beyond the # 15-tick horizon and a 6.5% arrival rate. 0 = off = today's uniform draw. TfilArriveTicks = max(0.0, getEnvFloat("TR_TFIL_ARRIVE_TICKS", 0.0)) + TfilHoldWhenTrapped = getEnvBool("TR_TFIL_HOLD_WHEN_TRAPPED", false) + TfilHoldMaxTicks = max(0, getEnvInt("TR_TFIL_HOLD_MAX_TICKS", 0)) if not TfilDiag: TfilLoss = TfilLossStats() loadTfilCommitEnv() @@ -394,6 +472,12 @@ type ## candidate set of the last pick (j145: ## lets a caller measure the REGRET of the ## draw instead of only the drawn value) + lastHeld: bool ## the last tick HELD position + ## (j153: TR_TFIL_HOLD_WHEN_TRAPPED, + ## the safe set was empty) + holdTicks: int ## j154: ticks HELD in the current + ## empty-safe-set streak; reset to 0 + ## when a safe tile is taken lastPickSafe: int ## how many SAFE tiles (pathMaxHeat ## <= PathDangerThreshold) the last pick ## drew from (j146: the size of the set @@ -428,6 +512,7 @@ proc resetRound*(m: var TFILModule) = m.fire.reset() m.commitTicks = 0 m.commitAge = 0 + m.holdTicks = 0 ## j154: the bounded hold never survives a round m.cachedHull = @[] m.cachedInsideTiles = @[] m.blockedTile = (col: 0, row: 0, active: false) @@ -443,6 +528,7 @@ proc resetRound*(m: var TFILModule) = m.picks = 0 m.lastPickPromoted = false m.lastPickMinTurn = 0.0 + m.lastHeld = false m.lastPickSafe = 0 # ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ────────────────── @@ -546,6 +632,28 @@ proc detectFires(m: var TFILModule, ws: WorldState) = " eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp m.fire.endScan() +proc bulletPanic*(m: TFILModule, selfX, selfY, horizon: float): bool = + ## PANIC RELEASE (j154). True when a TRACKED bullet's straight path comes + ## within its own CORE of where WE are at any time in `[0, horizon]` ticks. + ## Closest approach of a straight ray is `t* = ((self - b) . v) / |v|^2`; the + ## `t* < 0` case is the `dot < 0` reap in `advanceBullets` (it is already past + ## us) and `t* > horizon` is "not inside the window". The prediction is the + ## tracked ghost's OWN position/velocity — the same model `pathMaxHeat` decays + ## by and `advanceBullets` integrates — so there is no second arrival model in + ## this file. Exported so the guard test can call it directly. + if horizon <= 0.0: return false + for b in m.bullets: + let v2 = b.velX * b.velX + b.velY * b.velY + if v2 < 1e-9: continue + let dx = selfX - b.x + let dy = selfY - b.y + let t = (dx * b.velX + dy * b.velY) / v2 + if t < 0.0 or t > horizon: continue + let mx = dx - t * b.velX + let my = dy - t * b.velY + if sqrt(mx * mx + my * my) <= bulletRadii(b.power).core: return true + false + 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 @@ -725,6 +833,76 @@ proc turnWeights*(turns: openArray[float], bias, refDeg: float): seq[int] = result.add max(1, int(round(1.0 + bias * (1.0 - max(0.0, t - refDeg) / 180.0)))) +# ── j152: the geometric DRAW ──────────────────────────────────────────────── +# The owner: "choose tiles pool not only from the heat point but from a +# geometrically position too". Heat is already a HARD filter (unchanged); this is +# the second half — the distribution the draw samples from. +# +# WHY THIS IS NOT j9 AGAIN. j9 (`TR_TFIL_TURN_BIAS`) down-weighted the turn among +# the non-empty safe set and measured a live null. This runs on the pool the draw +# ALREADY runs on, which for ~65% of picks is the 2 promoted least-hot tiles that +# broke the heat filter — j9 could not see those at all. +const GeoDegPerTick = 12.0 ## distance cost, in "effective degrees": a 15-tick + ## trip (the commitment horizon) costs the same as a + ## 180 deg turn, so ONE tau knob means the same + ## thing in `turn` and `dist` mode. + +proc geoCosts*(turns, ttas: openArray[float], dim: TfilGeoDim): seq[float] = + ## Per-candidate cost in effective degrees. Never filters: it only re-orders + ## and re-weights tiles that already passed the heat filter. + for i in 0.. hold this tick # Hull + inside-tiles: only recompute on replan tick (commitTicks == 0) type TileRef = tuple[col, row: int] @@ -1097,8 +1276,11 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t else: blockedTiles.add t let safePre = safeTiles.len # j150: the safe set BEFORE the "keep 2" promotion + let safeEmpty = safePre == 0 if safeTiles.len < 2: - # Fallback: promote the least-hot blocked tiles until we have 2 + # Fallback: promote the least-hot blocked tiles until we have 2. j154 runs + # this EVEN when a hold is armed, so the panic release has a real fallback to + # fall back ON; the hold only ever DISCARDS the promotion, at the pick site. # ponytail: O(n) scan on already-sorted seq — fine for small N let needed = 2 - safeTiles.len let promote = min(needed, blockedTiles.len) @@ -1173,7 +1355,31 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = # 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: + # ── j153/j154: THE HOLD, decided here and nowhere else ───────────────────── + # The safe set was EMPTY, so there is nothing good to walk to. Today's answer + # is the promote-the-2 fallback above; the owner's answer is to stand still and + # let the field change. It is decided HERE, after the commitment block, so + # "are we on a replan tick?" is already answered — a hold replaces a REPLAN + # and can never interrupt a live commitment (j153's comment said that; its + # code did not enforce it, and a mid-commitment hold silently froze the bot). + var doPick = m.commitTicks == 0 and safeTiles.len > 0 + if m.commitTicks == 0 and safeEmpty: + let budgeted = TfilHoldMaxTicks > 0 + let hold = + if budgeted: + # The BOUNDED hold: at most N ticks per empty streak, released the tick + # a safe tile exists, and overridden outright by an inbound bullet. + m.holdTicks < TfilHoldMaxTicks and + not bulletPanic(m, ws.selfX, ws.selfY, + min(TfilHoldMaxTicks.float, HoldPanicTicks)) + else: + TfilHoldWhenTrapped # j153's ONE-tick hold, unchanged + if hold: + pickedHeld = true + doPick = false + if budgeted: inc m.holdTicks + + if doPick: # Filter out the blocked tile from candidates var candidates: seq[ScoredTile] for t in safeTiles: @@ -1196,7 +1402,20 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = for i in keep: narrowed.add candidates[i] candidates = narrowed var chosen = 0 - if (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2: + # j152: geometry shapes the DRAW, on top of the heat filter (never instead + # of it). Off by default: with `TR_TFIL_GEO_MODE=off` this whole block is + # skipped and the draw below is byte-for-byte today's. + # ponytail: takes precedence over TfilNoRev/TfilTurnBias (both also default + # off) instead of composing weights; compose if two are ever armed at once. + let geoOn = TfilGeoMode != gdoOff and TfilGeoTau > 0.0 + if geoOn and candidates.len >= 2: + var gturns: seq[float] + var gttas: seq[float] + for t in candidates: + gturns.add t.turnDeg + gttas.add t.arriveTicks + chosen = geoPick(gturns, gttas, TfilGeoMode, TfilGeoForm, TfilGeoTau) + elif (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2: # Soft preferences — down-weight, never filter, and only ever among tiles # that already passed the hard heat filter above: # arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward, @@ -1253,6 +1472,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = m.commitTicks = TfilCommitTicks m.commitAge = 0 m.commitLava = m.lavaAt(ct.col, ct.row) + m.holdTicks = 0 # j154: a safe tile was taken -> the budget refills m.blockedTile.active = false # clear after successful pick # log-only: reversal test against the travel direction @@ -1359,10 +1579,24 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand = ",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" & (if pickedRev: "1" else: "0") & ",\"mid\":" & (if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval & + ",\"hold\":" & (if pickedHeld: "1" else: "0") & + ",\"ht\":" & $m.holdTicks & ## j154: ticks held in the current streak ",\"picks\":" & $m.picks & "}") if pickedThisTick: m.replanReason = rrNone + m.lastHeld = pickedHeld # ── Steering ───────────────────────────────────────────────────────────────── + # j153/j154: HOLD. `speed: 0.0` is how this module already says "stop" (the + # already-at-target case below returns the same command), so holding needs no + # new signal, and the hold path is byte-identical to the stop path the bot + # already emits every time it reaches its dodge tile. The gun is NOT in this + # module — computeMove never touches fire, and `ModularBot.nim` aims and + # fires from tracked state AFTER `go()` on every tick regardless of the speed + # it just commanded — so a held tick still fires exactly as before. Guarded in + # `test_tfil_commit_env.nim` (the fire detector still latches a wave on a + # held tick). + if pickedHeld: + return (speed: 0.0, turnRate: 0.0) let stepDx = m.commitTarget.x - ws.selfX let stepDy = m.commitTarget.y - ws.selfY let dist2 = stepDx*stepDx + stepDy*stepDy diff --git a/common_libs/tests/measure_tfil_pick_defects.nim b/common_libs/tests/measure_tfil_pick_defects.nim index 9c16eca..ed977e5 100644 --- a/common_libs/tests/measure_tfil_pick_defects.nim +++ b/common_libs/tests/measure_tfil_pick_defects.nim @@ -14,6 +14,11 @@ ## safePre size of the safe set BEFORE the "keep 2" promotion ## hotAtTta the destination tile was OVER the threshold `tta` ticks ## later, on the recorded (true) future <- the feasibility test +## tile the chosen (col,row) <- j152: pick DIVERSITY +## j152: every row of the sweep table also reports the DIVERSITY cost (distinct +## tiles, entropy, top-tile share). j51 measured the randomness in this draw as +## LOAD-BEARING, so a geometry weight that improves the geometry numbers while +## collapsing the distribution is a regression, not a win. ## No battle, no Java, no server, no behaviour change. ## ## Run: @@ -21,7 +26,7 @@ ## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...] ## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ... -import std/[os, strformat, math, algorithm, json, sets, random, sequtils] +import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables] import std/strutils except fromHex # `fromHex` would clash with color.fromHex import gun_harness/offline_range # Private-field access: include (do NOT import) the shipped mover. @@ -36,6 +41,7 @@ type Pick = object safePre, cand: int hotAtTta: bool ## destination over threshold when we would arrive reached: bool ## we actually got within ArriveRadius by then + col, row: int ## the chosen tile (diversity) proc loadRoundStarts(path: string): HashSet[int] = result = initHashSet[int]() @@ -100,7 +106,8 @@ proc replay(path: string, seed: int): seq[Pick] = destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed, promoted: m.lastPickPromoted, safePre: safeSetSize(m, TfilDangerThreshold), - cand: m.lastPickSafe, hotAtTta: false, reached: false) + cand: m.lastPickSafe, hotAtTta: false, reached: false, + col: cc, row: cr) pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high) # 2. the arrival probe on the recorded true future var keep: seq[tuple[col, row: int; at: int; idx: int]] @@ -122,6 +129,7 @@ proc mean(x: seq[float]): float = proc pc(x: float): string = &"{100.0 * x:.1f}%" proc f1(x: float): string = &"{x:.1f}" +proc f2(x: float): string = &"{x:.2f}" proc report(label, path: string, picks: seq[Pick]) = echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}" @@ -158,25 +166,126 @@ proc report(label, path: string, picks: seq[Pick]) = # ── driver ─────────────────────────────────────────────────────────────────── let args = commandLineParams() -let fixtures = if args.len > 0: args - else: @["/tmp/firelag_live2/tfil_on/run1.jsonl", - "/tmp/firelag_live2/tfil_off/run1.jsonl", - "/tmp/firelag_live2/strafe_on/run1.jsonl", - "/tmp/firelag_live2/strafe_off/run1.jsonl", - currentSourcePath().parentDir.parentDir.parentDir / - "tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl", - currentSourcePath().parentDir.parentDir.parentDir / - "tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl", - currentSourcePath().parentDir.parentDir.parentDir / - "tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl", - currentSourcePath().parentDir.parentDir.parentDir / - "tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"] -var total: seq[Pick] -for f in fixtures: - if not fileExists(f): - echo "skip (missing): ", f; continue - for seed in [7, 8, 9]: - let p = replay(f, seed) - total.add p - if seed == 7: report("seed 7", f, p) # per-fixture detail, first seed only -report("ALL FIXTURES x 3 SEEDS", "", total) +let detail = "--detail" in args +let fixtures: seq[string] = + block: + if detail: + var v: seq[string] + for a in args: + if not a.startsWith("--"): v.add a + v + else: + @["/tmp/firelag_live2/tfil_on/run1.jsonl", + "/tmp/firelag_live2/tfil_off/run1.jsonl", + "/tmp/firelag_live2/strafe_on/run1.jsonl", + "/tmp/firelag_live2/strafe_off/run1.jsonl", + currentSourcePath().parentDir.parentDir.parentDir / + "tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl", + currentSourcePath().parentDir.parentDir.parentDir / + "tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl", + currentSourcePath().parentDir.parentDir.parentDir / + "tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl", + currentSourcePath().parentDir.parentDir.parentDir / + "tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"] + +# ── j152: the sweep, with the DIVERSITY cost on every row ──────────────────── +## (label, TR_TFIL_GEO_MODE, TR_TFIL_GEO_TAU, TR_TFIL_ARRIVE_TICKS) +type Arm = tuple[label, mode, tau, arrive: string] + +proc diversity(picks: seq[Pick]): tuple[distinctN, topShare, entBits, normEnt: float] = + ## Shannon entropy (bits) of the CHOICE distribution over tiles. `normEnt` is + ## H / log2(distinct): 1.0 = the arm spreads its picks over exactly as many + ## tiles as the baseline, 0.0 = every pick is the same tile. + var counts: Table[(int, int), int] + for p in picks: counts[(p.col, p.row)] = counts.getOrDefault((p.col, p.row)) + 1 + result.distinctN = counts.len.float + if picks.len == 0: return + var h = 0.0 + var top = 0 + for _, n in counts.pairs: + let q = n.float / picks.len.float + h -= q * log2(q) + top = max(top, n) + result.topShare = top.float / picks.len.float + result.entBits = h + result.normEnt = if result.distinctN > 1.0: h / log2(result.distinctN) else: 0.0 + +proc pctS(x, n: int): string = + if n == 0: return " n/a" + pc(x.float / n.float) +proc isPerp(p: Pick): bool = p.turn > PerpDeg +proc isPromoted(p: Pick): bool = p.promoted +proc isFar(p: Pick): bool = p.tta > 15.0 +proc isReached(p: Pick): bool = p.reached +proc isHotAtTta(p: Pick): bool = p.hotAtTta + +proc row(label: string, picks: seq[Pick]): string = + let n = picks.len + let emp = picks.filterIt(isPromoted(it)) + let nes = picks.filterIt(not isPromoted(it)) + let perp = picks.filterIt(isPerp(it)).len + let perpE = emp.filterIt(isPerp(it)).len + let perpN = nes.filterIt(isPerp(it)).len + let far = picks.filterIt(isFar(it)).len + let reach = picks.filterIt(isReached(it)).len + let hot = picks.filterIt(isHotAtTta(it)).len + let d = diversity(picks) + &"{label:<22} {pctS(perp, n):>7} {pctS(perpE, emp.len):>7} {pctS(perpN, nes.len):>7}" & + &" {pctS(far, n):>7} {f1(mean(picks.mapIt(it.tta))):>6}" & + &" {pctS(reach, n):>7} {pctS(hot, n):>7} {pctS(emp.len, n):>7}" & + &" {d.distinctN.int:>6} {f2(d.entBits):>6} {f2(d.normEnt):>6} {pc(d.topShare):>7}" & + &" {f1(mean(picks.mapIt(it.cand.float))):>5}" + +proc header(): string = + result = "arm".align(22, ' ') + for (h, w) in [("perp", 7), ("perpE", 7), ("perpN", 7), ("far", 7), ("mtta", 6), + ("REACH", 7), ("hotArr", 7), ("empty", 7), ("tiles", 6), + ("Hbits", 6), ("H/", 6), ("top1", 7), ("cand", 5)]: + result &= " " & h.align(w, ' ') + +# `perpE`/`perpN` = the perpendicular rate in the FORCED (empty safe set) and the +# non-empty populations; `REACH` = the headline (tile actually stood on at tta); +# `tiles`/`Hbits`/`H/`/`top1` = the diversity cost; `cand` = mean draw-set size. +let arms: seq[Arm] = @[ + ("BASELINE (off)", "off", "0", "0"), + ("turn-soft tau90", "turn-soft", "90", "0"), + ("turn-soft tau45", "turn-soft", "45", "0"), + ("turn-soft tau20", "turn-soft", "20", "0"), + ("turn-topk", "turn-topk", "45", "0"), + ("turn-rej tau60", "turn-rej", "60", "0"), + ("dist-soft tau90", "dist-soft", "90", "0"), + ("dist-soft tau30", "dist-soft", "30", "0"), + ("both-soft tau90", "both-soft", "90", "0"), + ("both-soft tau45", "both-soft", "45", "0"), + ("both-soft tau20", "both-soft", "20", "0"), + ("both-topk", "both-topk", "45", "0"), + ("both-rej tau60", "both-rej", "60", "0"), + # j151 interaction: a soft distance preference vs the HARD arrival bound. + ("arrive15 (j151)", "off", "0", "15"), + ("arrive15+both t45", "both-soft", "45", "15")] + +echo "\n", header() +for a in arms: + putEnv("TR_TFIL_GEO_MODE", a.mode) + putEnv("TR_TFIL_GEO_TAU", a.tau) + putEnv("TR_TFIL_ARRIVE_TICKS", a.arrive) + var picks: seq[Pick] + for f in fixtures: + if not fileExists(f): continue + for seed in [7, 8, 9]: picks.add replay(f, seed) + echo row(a.label, picks) + +# ── per-fixture detail (--detail only), for the BASELINE arm ──────────────── +if detail: + putEnv("TR_TFIL_GEO_MODE", arms[0].mode) + putEnv("TR_TFIL_GEO_TAU", arms[0].tau) + putEnv("TR_TFIL_ARRIVE_TICKS", arms[0].arrive) + var total: seq[Pick] + for f in fixtures: + if not fileExists(f): + echo "skip (missing): ", f; continue + for seed in [7, 8, 9]: + let p = replay(f, seed) + total.add p + if seed == 7: report("seed 7", f, p) + report("ALL FIXTURES x 3 SEEDS", "", total) diff --git a/common_libs/tests/test_tfil_commit_env.nim b/common_libs/tests/test_tfil_commit_env.nim index fa443d6..5808d86 100644 --- a/common_libs/tests/test_tfil_commit_env.nim +++ b/common_libs/tests/test_tfil_commit_env.nim @@ -37,7 +37,7 @@ ## 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/[os, json, random, math, sequtils, sets] 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. @@ -1078,6 +1078,407 @@ proc testJ151() = check "j151: clearing the knob restores today's pick stream exactly", replayJ151(0.0).picks == off.picks +# ── j152: the GEOMETRIC DRAW (TR_TFIL_GEO_MODE / TR_TFIL_GEO_TAU, default off) ─ +## Heat still gates the pool with the same hard filter; geometry only re-weights +## the survivors of that filter — INCLUDING the 2 promoted least-hot tiles the +## ~65% forced picks choose from, which is what j9 (`TR_TFIL_TURN_BIAS`) could +## not see. What must hold: +## 1. OFF by default and the OFF path is today's uniform draw byte-for-byte +## (the golden check #1 above runs with the knobs unset and is that proof; +## the last check here adds "off" == "unset" for the same seed). +## 2. NO STARVATION: a pool in which EVERY tile is perpendicular still returns +## a pick, in every form — the weight may never empty or index past the +## pool, whatever the TAU. +## 3. `off` and an all-ties pool both degenerate to the uniform draw. +## 4. The form is parsed, and junk stays off. +proc testJ152() = + delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU") + loadTfilCommitEnv() + check "j152: both geometry knobs default to OFF (today's uniform draw)", + TfilGeoMode == gdoOff and TfilGeoTau == 0.0 + + check "j152: the mode string parses both axes (dim + form)", + parseGeo("both-rej") == (gdoBoth, gfRej) and + parseGeo("turn-topk") == (gdoTurn, gfTopK) and + parseGeo("dist-soft") == (gdoDist, gfSoft) and + parseGeo("turn") == (gdoTurn, gfSoft) + check "j152: junk and 'off' both parse to OFF, never to a live arm", + parseGeo("off").dim == gdoOff and parseGeo("sideways").dim == gdoOff + + # 2. NO STARVATION: a pool where EVERY tile is 150 deg off the heading, at + # three different distances. No form may return an index outside the pool. + randomize(1) + let allPerpT = @[150.0, 150.0, 150.0] + let allPerpD = @[2.0, 30.0, 48.0] + for form in [gfSoft, gfTopK, gfRej]: + for tau in [1.0, 20.0, 5000.0]: + var seen: seq[int] + for _ in 0..<300: + seen.add geoPick(allPerpT, allPerpD, gdoTurn, form, tau) + # NO STARVATION = a pick always exists and is in range. It is NOT "every + # tile stays reachable": topk and rej are hard forms BY DESIGN and may + # legitimately return one tile forever when the whole pool is bad. + check "j152: no starvation — an all-perpendicular pool still returns " & + "an in-range pick (" & $form & ", tau " & $tau & ")", + seen.len == 300 and seen.allIt(it in 0..2) + + # 3. every tile costs the same => every weight ties => the uniform draw + randomize(2) + var tieSeen: seq[int] + for _ in 0..<300: + tieSeen.add geoPick(@[40.0, 40.0, 40.0], @[10.0, 10.0, 10.0], gdoTurn, gfSoft, 45.0) + check "j152: an all-ties pool degenerates to the uniform draw (all 3 seen, " & + "none starved)", tieSeen.toHashSet().len == 3 + var hitFar = 0 + randomize(3) + for _ in 0..<400: + if geoPick(@[0.0, 180.0], @[1.0, 1.0], gdoTurn, gfSoft, 10.0) == 0: inc hitFar + check "j152: the soft form really tilts (a straight-ahead tile is drawn " & + ">" & $hitFar & "/400 of the time with tau=10)", hitFar > 300 + + # 4. "off" == "unset" for the same seed: the shipped stream, unchanged. + let a = replayJ151(0.0) + delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU") + loadTfilCommitEnv() + let b = replayJ151(0.0) + check "j152: geometry off reproduces the shipped draw exactly (same picks, " & + "same mean tta, same pool)", a.picks == b.picks and + a.meanTta == b.meanTta and a.meanPool == b.meanPool + +# ── j153: HOLD WHEN TRAPPED (TR_TFIL_HOLD_WHEN_TRAPPED, default 0 = off) ──── +## The owner's rule: "if no tile is found to go, to not choose the less +## dangerous, but to stay still! the next tick probably the situation already +## changed and we did not commit to any dangerous place." +## What must hold, and nothing more: +## 1. OFF by default, and the OFF stream is byte-for-byte today's (the golden +## check #1 above already proves the default path; this adds the explicit +## "unset == 0 == 1-off-by-parsing" arm). +## 2. ON + EMPTY safe set => no movement command for that tick. +## 3. The hold is ONE tick: it never latches, and a later safe tile IS taken +## (no stuck bot, no held-then-forever-silent). +## 4. Holding does not skip the rest of the tick: the bullet tracking the GUN +## and the lava field are updated exactly as on a non-held tick. (The gun +## itself lives in the bot loop, not in this module — computeMove never +## emits a fire command — so the real risk is a hold that `return`s too +## early and freezes the bullet tracker; that is what this checks.) +type HoldRec = object + call: int + spd, trn: float + held: bool + picked: bool + bullets: int ## tracked bullets after this tick (the fire tracker's) + +proc replayJ153(hold: bool): seq[HoldRec] = + putEnv("TR_TFIL_HOLD_WHEN_TRAPPED", (if hold: "1" else: "0")) + loadTfilCommitEnv() + let states = loadStates() + let starts = loadRoundStarts() + randomize(Seed) + var m = initTFIL() + var lastPicks = 0 + for i in 0.. 0 + + let on = replayJ153(true) + var held, heldMoved, heldPicked = 0 + var nonHeldMoving = 0 + for i in 0.. 0.001: inc heldMoved + if on[i].picked: inc heldPicked + elif abs(on[i].spd) > 0.001: inc nonHeldMoving + check "j153: with the knob ON the safe set really is empty often enough to " & + "matter (" & $held & " held ticks of " & $on.len & ")", + held > on.len div 100 + check "j153: a held tick emits NO movement (speed 0) and no pick " & + "(" & $heldMoved & " moving holds, " & $heldPicked & " held picks)", + held > 0 and heldMoved == 0 and heldPicked == 0 + check "j153: the hold is not a freeze — " & $nonHeldMoving & " non-held " & + "ticks still move and the bot still picks", + nonHeldMoving > 0 and on.filterIt(it.picked).len > 0 + + # no latch: a held tick must be followed by movement again (within a couple of + # ticks), and a pick must still be taken somewhere after the holds. + # A HOLD is not a latch: the hold is decided at the pick site, and the pick + # site only runs when the commitment has expired, so every held tick is a FRESH + # evaluation of the field. Observable consequence: hold runs end, and the tick + # after a run is a moving tick again. (A latching implementation would show ONE + # run per round and ~0 resumptions.) A long run therefore means a long trap, not + # a stuck bot — that is why the run LENGTH is deliberately not asserted. + var runs = 0 + var resumed = 0 + var worst = 0 + var run = 0 + for i in 0.. 0: + inc runs + worst = max(worst, run) + if abs(on[i].spd) > 0.001: inc resumed + run = 0 + if run > 0: + inc runs + worst = max(worst, run) + var pickedAfter = 0 + var sawHold = false + for r in on: + if r.held: sawHold = true + elif sawHold and r.picked: inc pickedAfter + check "j153: the hold is NOT a latch — " & $resumed & " of " & $runs & + " maximal hold runs resume moving on the very next tick (longest run " & + $worst & " ticks = a trap that lasts, not a stuck bot) and " & + $pickedAfter & " picks happen after a hold", + runs > 0 and resumed * 2 > runs and pickedAfter > 0 + + # the gun path: a held tick must leave the bullet tracker exactly where a + # non-held tick would. If the hold returned before the tracker update, the + # bullet counts would diverge from the first hold onwards. + var firstDiv = -1 + for i in 0.. 16/18/32/32/48). 16 is also the +# FIRST window that admits the enemy's SECOND shot at all, so nothing shorter +# can be surprised by a third bullet. +# +# A fully hot field is painted with the virtual pillar at radiance 0 +# (`max(0, hotness - d*0) = hotness` on every tile) — the one heat source that +# covers the whole reachable hull at once, so the safe set is provably empty. +proc testJ154() = + delEnv("TR_TFIL_HOLD_MAX_TICKS") + loadTfilCommitEnv() + check "j154: TR_TFIL_HOLD_MAX_TICKS defaults to 0 = today's behaviour exactly", + TfilHoldMaxTicks == 0 and not TfilHoldWhenTrapped + + # 1. DEFAULT PARITY: an explicit 0 is indistinguishable from unset, over the + # whole fixture, tick for tick. (The golden above covers UNSET; this covers + # the explicit zero the owner would put in an arm.) + let unset = replay(loadStates(), loadRoundStarts()) + putEnv("TR_TFIL_HOLD_MAX_TICKS", "0") + loadTfilCommitEnv() + let zero = replay(loadStates(), loadRoundStarts()) + var diff = -1 + if unset.len != zero.len: diff = min(unset.len, zero.len) + else: + for i in 0.. 0 + + # 2. knob parsing + putEnv("TR_TFIL_HOLD_MAX_TICKS", "16"); loadTfilCommitEnv() + check "j154: TR_TFIL_HOLD_MAX_TICKS=16 is read", TfilHoldMaxTicks == 16 + putEnv("TR_TFIL_HOLD_MAX_TICKS", "junk"); loadTfilCommitEnv() + check "j154: a malformed value falls back to 0 (off)", TfilHoldMaxTicks == 0 + putEnv("TR_TFIL_HOLD_MAX_TICKS", "-8"); loadTfilCommitEnv() + check "j154: a negative value clamps to 0", TfilHoldMaxTicks == 0 + delEnv("TR_TFIL_HOLD_MAX_TICKS"); loadTfilCommitEnv() + + const HoldN = 4 ## the budget under test; any small N exercises it + const Hot = 100.0 ## every tile at 100 >> PathDangerThreshold 10 + let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0, + enemySpeed: 0.0, enemyEnergy: 100.0, + selfX: 400.0, selfY: 300.0, selfHeading: 0.0, + selfSpeed: 8.0, selfEnergy: 100.0, + arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 0, + enemies: @[]) + + ## One tick. `hot` = the pillar heat (0 = a clean field). A non-nil `bullet` + ## is installed as the tracked set, which is the ONLY way an inbound bullet + ## ever exists here (the enemy is out of the arena in these worlds). + proc tick(m: var TFILModule, hot: float, t: int, + bullet: TrackedBullet = TrackedBullet(alive: false)): MoveCommand = + PillarHotness = hot + PillarRadiance = 0.0 + var w = ws + w.tick = t + if bullet.alive: m.bullets = @[bullet] + result = m.computeMove(w) + + ## A module with the grid initialised and NO live commitment, so tick 0 of a + ## scenario is a REPLAN tick (where, and only where, a hold may be taken). + proc fresh(): TFILModule = + PillarHotness = 0.0; PillarRadiance = 0.0 + result = initTFIL() + randomize(Seed) + discard tick(result, 0.0, 0) + result.commitTicks = 0 + result.picks = 0 + result.commitTarget = (x: 400.0, y: 300.0) + + type Rec = tuple[held: bool, ht: int, picked: bool] + + ## The held/pick pattern of a run, plus the counter at each tick. + proc run(m: var TFILModule, hot: seq[float]): seq[Rec] = + for t, h in hot: + let before = m.picks + discard tick(m, h, t + 1) + result.add (held: m.lastHeld, ht: m.holdTicks, picked: m.picks != before) + + # A 1-tick commitment makes every tick a replan tick, so the scenario is a + # clean read of the hold rule alone (no commitment state leaking in). + putEnv("TR_TFIL_COMMIT_TICKS", "1") + putEnv("TR_TFIL_HOLD_MAX_TICKS", $HoldN) + loadTfilCommitEnv() + + # 3. the BOUND: at most N consecutive held ticks, then the normal promote-the-2 + # fallback takes over — the hold can never latch. + let hotAll = @[Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, + Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, 0.0] + var m = fresh() + let r = run(m, hotAll) + var firstPick = -1 + for i, e in r: + if e.picked: firstPick = i; break + echo "\n j154 run (held/ht/picked per tick, index: value):" + for i, e in r: + echo " ", i, ": ", (if e.held: "H" else: "."), e.ht, + (if e.picked: " P" else: " ") + check "j154: with the safe set EMPTY the mover HOLDS (" & $HoldN & + " ticks) instead of promoting, and releases into a pick on tick " & + $(firstPick + 1) & " — the bound is N, not 'until a tile appears'", + firstPick == HoldN and r[0].held and r[HoldN - 1].held and + r[HoldN - 1].ht == HoldN and not r[HoldN].held and r[HoldN].picked + + # 4. RELEASE THE MOMENT A SAFE TILE EXISTS: the field cools at index 10, and + # that very tick is a pick, not a hold — no tick of latency. + check "j154: the hold releases on the SAME tick a safe tile appears " & + "(index 10 cooled -> picked=" & $r[10].picked & ", held=" & + $r[10].held & ", counter=" & $r[10].ht & ")", + r[10].picked and not r[10].held and r[10].ht == 0 + # ... and the budget REFILLS: a fresh empty streak holds a full N again, + # i.e. the bound is per streak and the counter is not cumulative. + let streak2 = r[11 .. ^1] + var held2 = 0 + for e in streak2: + if e.held: inc held2 + check "j154: the counter RESET when the safe tile was taken — the second " & + "empty streak holds a full N again (" & $held2 & " ticks), never the " & + "accumulated " & $r[10].ht & "+" & $r[11].ht, + streak2[0].held and held2 >= HoldN and r[11].ht == 1 + + # 5. the held command is the SAME stop the mover already emits at its target, + # and the gun path is untouched: on a held tick the fire detector still + # latches the enemy's wave (the bot aims and fires from tracked state after + # go(), on every tick, whatever speed it just commanded). + m = fresh() + PillarHotness = Hot; PillarRadiance = 0.0 + var wFar = ws + wFar.enemies = @[EnemyInfo(id: 1, x: 760.0, y: 300.0, heading: 180.0, + speed: 0.0, energy: 100.0)] + wFar.tick = 1 + discard m.computeMove(wFar) # enemy seen at 100.0 energy + m.commitTicks = 0 # armed: a replan tick, as above + wFar.tick = 2 + wFar.enemies[0].energy = 98.5 # a 1.5 drop = a 1.5-power shot + let gunCmd = m.computeMove(wFar) + check "j154: the gun still fires while holding — the mover held (" & + $m.lastHeld & ") on the very tick the enemy fired, and the fire " & + "detector still latched the wave (" & $m.bullets.len & " tracked)", + m.lastHeld and m.bullets.len > 0 + check "j154: the held command is the stop the mover already emits at its " & + "target (speed 0, turn 0) — no new signal, no movement side effect", + gunCmd.speed == 0.0 and gunCmd.turnRate == 0.0 + + # 6. PANIC RELEASE (required). A tracked bullet on a collision course, 9 ticks + # out, overrides the hold on the tick it exists. The same bullet offset + # laterally does NOT, so the release is specific, not "any bullet". + # The budget is the DERIVED 16 here, so the horizon is min(16, 16) = 16. + putEnv("TR_TFIL_HOLD_MAX_TICKS", "16") + loadTfilCommitEnv() + let inbound = TrackedBullet(originX: 570.0, originY: 300.0, x: 570.0, y: 300.0, + velX: -17.0, velY: 0.0, power: 1.0, + alive: true, age: 0) + let missing = TrackedBullet(originX: 570.0, originY: 500.0, x: 570.0, + y: 500.0, velX: -17.0, velY: 0.0, power: 1.0, + alive: true, age: 0) + m = fresh() + discard tick(m, Hot, 1, inbound) + check "j154: PANIC RELEASE — a tracked bullet 9 ticks from our position " & + "overrides the hold on the same tick (held=" & $m.lastHeld & + ", picked=" & $(m.picks > 0) & ")", + (not m.lastHeld) and m.picks > 0 and m.holdTicks == 0 + m = fresh() + let cmdMiss = tick(m, Hot, 1, missing) + check "j154: ... and it is SPECIFIC: the same bullet 200px off our line " & + "still holds (held=" & $m.lastHeld & "), so the release is an arrival " & + "test, not a bullet count", + m.lastHeld and m.picks == 0 and cmdMiss.speed == 0.0 + + proc withBullet(b: TrackedBullet): TFILModule = + result = initTFIL() + result.bullets = @[b] + check "j154: the panic horizon is the DERIVED budget min(N, 16) ticks — the " & + "arrival test fires inside it and not outside", + bulletPanic(initTFIL(), 400.0, 300.0, 16.0) == false and + bulletPanic(withBullet(inbound), 400.0, 300.0, 16.0) == true and + bulletPanic(withBullet(inbound), 400.0, 300.0, 4.0) == false + + # 7. a hold NEVER interrupts a live commitment (j153's comment claimed that; + # j154's code enforces it). Take a pick, keep the field hot, and the mover + # must keep driving to its committed target. + putEnv("TR_TFIL_COMMIT_TICKS", "15") + loadTfilCommitEnv() + m = fresh() + discard tick(m, 0.0, 1) # a clean field first: that tick PICKS + let pickedFirst = m.picks > 0 + let live = m.commitTicks + let cmdLive = tick(m, Hot, 2) # now the field goes fully hot + check "j154: a hold never interrupts a live commitment — with " & $live & + " ticks on the clock the mover keeps driving to its target (speed " & + $cmdLive.speed & "), it does not freeze", + pickedFirst and live > 0 and (not m.lastHeld) and cmdLive.speed != 0.0 + + putEnv("TR_TFIL_COMMIT_TICKS", "15") + delEnv("TR_TFIL_HOLD_MAX_TICKS") + loadTfilCommitEnv() + PillarHotness = 0.0; PillarRadiance = 0.0 + check "j154: clearing the knob restores today's behaviour exactly", + TfilHoldMaxTicks == 0 + # ── driver ─────────────────────────────────────────────────────────────────── testDefaultParity() @@ -1089,7 +1490,10 @@ when declared(loadTfilCommitEnv): testJ146() testJ147() testJ151() + testJ152() testJ150() + testJ154() + testJ153() if failures > 0: echo "\n", failures, " check(s) FAILED"