# 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".