Files
SirRoboGarage/docs/tfil_hold_budget_j154.md
SirStone 2223ca667e 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.
2026-09-27 10:23:46 +02:00

140 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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".