diff --git a/ModularBot_garage/.env.example b/ModularBot_garage/.env.example index e52fd17..485b65f 100644 --- a/ModularBot_garage/.env.example +++ b/ModularBot_garage/.env.example @@ -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 diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index c801388..7f35729 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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", diff --git a/docs/tfil_hold_budget_j154.md b/docs/tfil_hold_budget_j154.md new file mode 100644 index 0000000..428cb34 --- /dev/null +++ b/docs/tfil_hold_budget_j154.md @@ -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".