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.
7.3 KiB
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 isCommitTicks + 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:
computeMovenever emits fire, andModularBotaims and fires from tracked state aftergo()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".