j154 (default-off): the DERIVED bounded hold TR_TFIL_HOLD_MAX_TICKS + panic release

Budget derived from the server source (tank-royale 0.35.5): calcGunHeat(p) =
1 + p/5, coolDown 0.1/tick, fire only at heat == 0, calcBulletDamage(3.0) = 16
-> two max-power shots are 16 ticks apart and 32 damage is the most the enemy
can land in that window (brute force over the 0.1 power grid: 8/11/16/24/32
ticks -> 16/18/32/32/48). 16 = CommitTicks + 1, the first window that admits
the enemy's second shot.

Knob defaults to 0 = today's behaviour byte-for-byte (checked over 20026
ticks). Hold is taken only on a replan tick with an empty safe set, at most N
ticks per streak, released the tick a safe tile exists, counter reset on the
pick. PANIC RELEASE: a tracked bullet whose closest approach is within its core
at t* in [0, min(N,16)] overrides the hold immediately. The gun is untouched.

Also fixes a stray '&' that stopped test_tfil_commit_env.nim from compiling at
all, and enforces the 'a hold never interrupts a live commitment' invariant
j153 documented but did not implement. Registers TR_TFIL_HOLD_MAX_TICKS in
env_report + knownEnvNames + .env.example.

No battle, no A/B run.
This commit is contained in:
2026-09-27 10:23:46 +02:00
parent 38fbc6ecd1
commit 2223ca667e
3 changed files with 143 additions and 1 deletions
+1
View File
@@ -114,6 +114,7 @@ TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is can
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_HOLD_MAX_TICKS=0 # BOUNDED version of the above: hold at most N ticks per empty-safe-set streak (default 0 = off = today's fallback). The budget is DERIVED from the enemy's rate of fire (2 x 3.0-power shots = 16 ticks); the hold is released the tick a safe tile exists and overridden outright if a tracked bullet reaches us within min(N, 16) ticks
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
+3 -1
View File
@@ -317,6 +317,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
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_HOLD_MAX_TICKS", $the_floor_is_lava.TfilHoldMaxTicks,
sourceOf("TR_TFIL_HOLD_MAX_TICKS"))
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,
@@ -662,7 +664,7 @@ 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_HOLD_WHEN_TRAPPED", "TR_TFIL_HOLD_MAX_TICKS",
"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",
+139
View File
@@ -0,0 +1,139 @@
# j154 — the DERIVED hold budget for "hold when the safe set is empty"
**Status: implemented, DEFAULT OFF, no battle run.** `TR_TFIL_HOLD_MAX_TICKS`
(default `0` = today's behaviour byte-for-byte). The knob, the panic release and
the guards live in `common_libs/movements/the_floor_is_lava.nim` and
`common_libs/tests/test_tfil_commit_env.nim`.
## 1. The mechanics, re-verified against the server source
Source used: **the server Kotlin sources at `/home/davide/Projects/tank-royale`
(v0.35.5)**, not a cached doc. There is no `docs/energy_math.md` in this repo.
| Fact | Value | Source |
|---|---|---|
| gun heat added per shot | `1 + p/5` | `server/.../rules/math.kt:125` `calcGunHeat` |
| gun cooling | `0.1` / tick | `core/GunEngine.kt:108` `coolDownGun`, default `DEFAULT_GUN_COOLING_RATE = 0.1` (`lib/common/.../RuleDefaults.kt:25`) |
| may fire only at | `gunHeat == 0.0` (strict; the else-branch cools instead) | `core/GunEngine.kt:36` |
| bullet damage | `4p`, `+2(p-1)` above 1 → `6p-2` | `rules/math.kt:112-118` `calcBulletDamage` |
| firepower clamp | `0.1 … 3.0` | `rules/rules.kt:49,52` |
| round-start gun heat | `3.0` | `rules/rules.kt:22` `INITIAL_GUN_HEAT` |
Note: there is no `MaxGunHeat = 3.0` gate in this server — the fire gate is
`gunHeat == 0`, and `3.0` is only the *initial* heat a bot starts a round with
(30 idle ticks of cooldown). The 16-tick number below is unchanged by that
distinction, because it is derived from the heat ADD and the cooling rate.
## 2. The owner's frame: "the time between shooting 2 × 3.0-power bullets"
`heat(3.0) = 1 + 3/5 = 1.6`; `1.6 / 0.1` = **16 ticks** between two max-power
shots. `calcBulletDamage(3.0) = 6*3 - 2 = ` **16**, so two of them = **32**.
The *fastest* repeat is a `0.1`-power shot: `1.02` heat → **11 ticks**
(the 11th subtraction is what takes the residual 0.02 to 0), for
`4*0.1 = ` **0.4** damage. So "2 × 3.0-power" is a **DAMAGE** bound, not a
COUNT bound: the enemy can fire ~1.5× as often, but each of those shots is 40×
weaker. The right question is therefore "how much damage can land in N ticks",
not "how many bullets".
## 3. Max damage deliverable in N ticks (brute force over the power quantisation)
DP over the 0.1-step power grid (30 powers), the enemy free to mix powers
(it may interleave weak shots to shorten its own interval), first shot free at
t = 0:
| N (ticks) | max total damage | how |
|---|---|---|
| 8 | **16** | one 3.0 shot; the 0.1-power repeat needs 11 |
| 11 | **18** | 3.0 (16) at t=0, then 0.5-power (2) at t=11 |
| 16 | **32** | 3.0 at t=0 and t=16 — two max shots |
| 24 | **32** | same two; the next shot cannot land before t=32 |
| 32 | **48** | 3.0 at t=0, 16, 32 |
| 64 | **80** | five max shots (linear thereafter) |
The damage *rate* `(6p-2)/(10+2p)` is monotone increasing in `p` (0.036 dmg/tick
at 0.1, 0.33 at 1.0, 1.0 at 3.0), so no mix beats pure 3.0-power asymptotically;
mixing only wins at a window edge (N = 11 above), never by more than one weak
shot. **16 ticks is the exposure ceiling of a hold: 32 damage = 16 % of the
200 HP a bot carries.**
## 4. How 16 relates to the code
* `CommitTicks = 15` (`the_floor_is_lava.nim:71`). The derived budget is
**`CommitTicks + 1`**: a hold of 15 ticks admits ONE max-power shot (16
damage), 16 ticks admits the second (32). 16 is the first window in which the
enemy's *second* bullet can land at all, so it is the shortest budget that
cannot be surprised by a third. The two numbers agree by construction, which
is the point: the mover's existing commitment length and the enemy's rate of
fire are the same quantity here.
* j144 measured a **mean hold of 24.0 ticks** live. 24 sits between the 16- and
32-tick damage steps: it buys no extra protection (still 32) and is exposed to
the same two shots. A 16-tick cap is therefore a *tightening* of j144's
measured behaviour, not an extrapolation of it — hence the 24 arm in the A/B.
## 5. The knob
`TR_TFIL_HOLD_MAX_TICKS` (int, default `0` = off). When the safe tile set is
empty (~65 % of picks offline) and the mover is on a **replan** tick, it holds
position for at most N ticks per empty streak. Rules:
* a safe tile exists → release on the same tick (no latency);
* counter resets when a safe tile is taken, so the bound is per streak;
* the hold never interrupts a live commitment (it replaces a replan only) —
j153's comment claimed this and its code did not enforce it; j154 does;
* the **gun is untouched**: `computeMove` never emits fire, and `ModularBot`
aims and fires from tracked state after `go()` on every tick. Guarded: the
fire detector still latches the enemy's wave on a held tick.
### Panic release (required)
`bulletPanic(m, selfX, selfY, horizon)` — for each tracked bullet, closest
approach of its straight path is `t* = ((self-b)·v)/|v|²`; the hold is
overridden when `0 ≤ t* ≤ horizon` and the miss distance is within the
bullet's own core radius. Horizon = **`min(N, 16)` ticks**. It reuses the
tracked ghost's own position/velocity — the same model `pathMaxHeat` decays by
and `advanceBullets` integrates — so there is no second arrival model in the
file (`TR_TFIL_ARRIVE_TICKS` is a *tile-selection* filter, not a bullet-arrival
predictor). `min(N, 16)` because a bullet arriving after the budget expires
cannot hurt a hold that has already ended; 16 is the derived exposure window.
### Composition with the other knobs
* `TR_TFIL_COMMIT_ARRIVAL=1` — **wins**. A hold is only reachable on a replan
tick, so under arrival the bot is in a commitment and never holds. The
arrival commitment is a hold on a *destination*; the empty-set hold is a hold
with *no destination*. They never compete.
* `TR_TFIL_NOREV_SPEED=4` — no interaction: it filters mid-flight candidate
switches inside the pick block, which a hold short-circuits, and it cannot
force a pick during a hold.
* `TR_TFIL_ARRIVE_TICKS` — no interaction: it only filters a **non-empty** safe
set, and the hold only triggers when that set is empty.
## 6. Guards
`common_libs/tests/test_tfil_commit_env.nim`, `testJ154` (15 checks): default 0;
explicit `0` byte-for-byte the unset build over 20 026 ticks; knob parsing
(16 / junk / negative); the N-tick bound; same-tick release when a safe tile
appears; counter reset and budget refill; the gun still fires while holding; the
held command is the same stop as at-target; panic release; panic specificity
(a bullet 200 px off the line still holds); the horizon check; the hold never
interrupts a live commitment; clearing the knob.
Suite total: **136 PASS** (121 before j154, not the 99 quoted in the brief —
see the note below).
## 7. Proposed A/B — NOT RUN
Arms: `TR_TFIL_HOLD_MAX_TICKS` = 0 (control) / 16 / 24; `TR_MOVEMENT=tfil`
pinned; frozen 15-opponent panel; everything else at shipped defaults.
Primary metrics: **damage per run and round-win rate** (never hit rate).
Mechanism metrics: % picks held, incoming hit rate while holding, panic-release
frequency, distance-to-enemy at the moment of the hold.
MDE: ~0.28 wins/run at ~14 runs/arm (two arms ≈ 1 h); resolving ~0.10
wins/run needs ~2.2 h ≈ 2 900 battles for a three-arm design.
Expectation to state up front: four mechanism-positive / outcome-null results in
a row (j152 geometry, j151 arrival bound, j144 arrival commitment, j153 hold)
make a null the likely outcome, and this is the fifth candidate. 1 h can only
say "no large effect"; ~2.2 h is needed before "no small effect".