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