Compare commits
22 Commits
de5d02ba3f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7632aaba06 | |||
| 208f4a9092 | |||
| 55e92bc5e0 | |||
| 486e2a69c6 | |||
| 940fa44631 | |||
| 0df7763765 | |||
| 07866c99bd | |||
| 51bfa57067 | |||
| fe77056459 | |||
| 6cfb1698f1 | |||
| 64e23e29d1 | |||
| 23bce2dad5 | |||
| 4a1f3e1c88 | |||
| 7c5bc9ccbd | |||
| 2223ca667e | |||
| 38fbc6ecd1 | |||
| 94ffc63160 | |||
| a01141c959 | |||
| 0b74b01c4b | |||
| 5e213df8d9 | |||
| 32ec040e90 | |||
| d21f7ce5f5 |
+552
-45
@@ -10,19 +10,187 @@
|
||||
# Every value below IS the built-in default, so running the bot with this file
|
||||
# is identical to a clean run with no file at all. Delete a line (or comment it
|
||||
# out with #) and that knob falls back to the built-in default. An inline
|
||||
# `# comment` after a value is fine — the loader strips it.
|
||||
# `# comment` after a value is fine — the loader strips it (a `#` that follows
|
||||
# a space starts the comment; a `#` glued to the value, like `x#y`, is data).
|
||||
#
|
||||
# A few switches are PRESENCE-only (`existsEnv`): for those, OFF means the line
|
||||
# is absent, so they are shown commented out. Writing `=0` would still turn them
|
||||
# ON.
|
||||
# These values mirror the current defaults, not a frozen snapshot of one commit.
|
||||
# Regenerate this file whenever a default changes, or it will start lying.
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 1. ONE EXPERIMENT, END TO END
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# SNAPSHOT of the code at commit 5e32ec1. Regenerate this file whenever a default
|
||||
# changes, or it will start lying.
|
||||
# Pick ONE knob. Here the example is TR_TFIL_ARRIVE_TICKS, but the shape is the
|
||||
# same for every knob in this file.
|
||||
#
|
||||
# # 1. write the arm. In ModularBot_garage/.env, change ONE line:
|
||||
# # TR_TFIL_ARRIVE_TICKS=15.0
|
||||
# # A per-run file is better than editing .env, because it is how you
|
||||
# # GUARANTEE the arm: whatever else is in .env or in your shell, this
|
||||
# # file is the one that is applied.
|
||||
# cat > /tmp/arm_arrive15.env <<'EOF'
|
||||
# TR_MOVEMENT=tfil
|
||||
# TR_TFIL_ARRIVE_TICKS=15.0
|
||||
# EOF
|
||||
#
|
||||
# # 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing
|
||||
# # .env while the bot runs changes nothing. There is no live reload.
|
||||
# # (Two exceptions read lazily on first use: TR_PATTERN_RAD_* and a few
|
||||
# # TR_TMHORIZON_* — do not rely on either.)
|
||||
# cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
|
||||
#
|
||||
# # 3. CONFIRM IT TOOK EFFECT, before you read a single result line.
|
||||
# # `source: .env` = your file was applied. `source: default` = it was not.
|
||||
# grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
|
||||
# # [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
|
||||
# # [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
|
||||
# # A value showing `(source: default)` means YOUR FILE NEVER REACHED THE BOT.
|
||||
#
|
||||
# # 4. point at the file instead of copying it into .env:
|
||||
# TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
|
||||
# ./out/ModularBot --env-file /tmp/arm_arrive15.env
|
||||
# # This is what an A/B run does: one frozen binary, one env file per arm.
|
||||
# # A file you ASKED for and that does not exist stops the bot with an error
|
||||
# # (it never silently falls back); a missing default .env is silent.
|
||||
#
|
||||
# # 5. the module inventory, when you want to know what is on at all:
|
||||
# grep '^\[modules\]' /tmp/modularbot_stdout.log
|
||||
#
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 2. SAFE TO EXPERIMENT WITH RIGHT NOW
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# The honest list is SHORT. After the recent campaign most experimental knobs
|
||||
# are either never live-tested or already measured null/harmful, and this file
|
||||
# says so on every one of them. These four are safe in the sense that they
|
||||
# either cannot change a decision, or are the ones a measurement actually
|
||||
# supports.
|
||||
#
|
||||
# TR_GEO_DEBUG=on Draw-only: the candidate-tile geometry overlay.
|
||||
# Watch: the circle on the two tanks and each
|
||||
# heading line. Good: you can SEE the tile the
|
||||
# picker chose. Cannot change any decision.
|
||||
# TR_VBULLET_DEBUG=1 Draw-only: each admitted gun's virtual bullets.
|
||||
# TR_VBULLET_DEBUG_GUN=all
|
||||
# Watch: travelled path, aim ring, miss vector.
|
||||
# Good: you can see the signal the selector ranks
|
||||
# on. Also draw-only. Needs a gun in the rack.
|
||||
# TR_TFIL_DIAG=on Observability only, on the tfil mover. Fills the
|
||||
# per-pick LOSS HISTOGRAM. Watch: the tfil pick log
|
||||
# line. Good: the sReach/sCool/sSafe/sCand counts
|
||||
# tell you where tiles are lost. Provably does not
|
||||
# move a single command (guard-tested).
|
||||
# TR_MOVEMENT=tfil The long-shipped mover, as an explicit override.
|
||||
# Watch: nothing to compare against — it is the
|
||||
# same engine you had before j119. Good: you are
|
||||
# reproducing an older, documented behaviour. Only
|
||||
# do this together with the tfil knobs below.
|
||||
#
|
||||
# Anything else on this list is a MEASUREMENT, not a free change: read its
|
||||
# STATUS line before you type it.
|
||||
#
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 3. ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# TR_TFIL_GEO_MODE=both-rej REJECTED live, 420 battles, 15-opponent panel.
|
||||
# TR_TFIL_GEO_TAU=60 damage/run -8.83, p(sign-flip) = 0.0061,
|
||||
# Wilcoxon p = 0.011. Round wins null.
|
||||
# Docs: docs/tfil_geo_ab.md. DO NOT re-run it.
|
||||
# TR_RAM_FLOOR_ENERGY=5 CLEAN NULL, 900 battles, 450 runs/arm.
|
||||
# -0.018 wins/run, p(sign-flip) = 0.7676, under
|
||||
# a 0.1420 wins/run MDE. Docs:
|
||||
# docs/ram_floor_exhaustion_ab.md. DO NOT re-run
|
||||
# it — the mechanism fired on 0.04% of ticks, so
|
||||
# more runs buy resolution on an effect that is
|
||||
# not there.
|
||||
# TR_RAM_FLOOR_ENERGY=10/20 MEASURED COSTLY OFFLINE (24.7% of ticks blocked
|
||||
# at 20) and the live zone they guard is almost
|
||||
# empty: only 4.8% of shots are ever taken below
|
||||
# 10 energy. Do not go above 5.
|
||||
# TR_TMHORIZON_WINDOW=150 MEASURED HARMFUL live: 26.5% round wins vs 49.0%
|
||||
# for the shipped rack, p = 0.036. Keep 0.
|
||||
# TR_POWER_POLICY=0 MEASURED HARMFUL live: real hit rate 10.61% ->
|
||||
# 7.88%, p = 0.0012. Keep it on.
|
||||
# TR_TFIL_HEAT_TIME=1 MEASURED HARMFUL live at every tau tried
|
||||
# (3/5/9/15): tau15 alone is -22 damage/run,
|
||||
# p = 0.046. Keep it off.
|
||||
# TR_MOVEMENT=tfil_ring MEASURED: round wins 16/49 -> 6/49, p = 0.012. A
|
||||
# glass cannon — best live hit rate of anything
|
||||
# measured, half the survival. Do not ship.
|
||||
# TR_RACK_* (the full rack) MEASURED NEGATIVE VALUE: Pattern ALONE beats
|
||||
# the full 13-gun rack, p = 0.0012. Adding guns
|
||||
# costs rounds.
|
||||
# TR_TFIL_TURN_BIAS=9 LIVE NULL: +0.15 wins/run, p(sign) = 0.244, under
|
||||
# TR_TFIL_TURN_REF_DEG=0 a 0.30 MDE; 300 battles. Docs:
|
||||
# docs/movement_campaign.md (j145).
|
||||
# TR_RAM_OPPORTUNITY=on MEASURED not to convert: 0/59
|
||||
# opportunity -> contact. The finisher ram is the
|
||||
# only path that converts, and it is always on.
|
||||
#
|
||||
# READ THIS BEFORE YOU TRUST ANY "null" ABOVE. A null only excludes an
|
||||
# effect at or above the MDE that run resolved. The 15-opponent panel at
|
||||
# 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run; the j163 run
|
||||
# resolved 0.1420 wins/run. So "clean null" here means "no effect >= that
|
||||
# size", NOT "no effect".
|
||||
#
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 4. PRESENCE-GATED KNOBS — FOR THESE, `NAME=0` TURNS THE FEATURE ON
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/
|
||||
#
|
||||
# The knobs below are read with `existsEnv`, not by value. OFF means THE LINE
|
||||
# IS ABSENT. Writing `TR_POWER_LOG=0` does not disable the power log — it
|
||||
# ENABLES it, because 0 is a perfectly good value for a knob nobody reads.
|
||||
# That is why they are all shown COMMENTED OUT in this file: there is no
|
||||
# "off" spelling for them, only absence. To disable one, DELETE its line.
|
||||
#
|
||||
# TR_POWER_LOG one line per power-decision CHANGE
|
||||
# TR_RAM_LOG one line per ram start/stop, with the reason
|
||||
# TR_MOVEMENT_LOG movement band / range-class changes
|
||||
# TR_STRAFE_LOG one line per strafe tile pick
|
||||
# TR_SURF_LOG one line per wave-surfing decision
|
||||
# TR_FIRE_DIAG per-reading fire-detection tick/raw/correction
|
||||
# TR_RECORD_WORLDSTATE dump every observed world state to JSONL
|
||||
# TR_CAPTURE_AIM aim_scan/aim_fire records (gun id + bot belief).
|
||||
# KNOWN LIMIT: only shots that PASSED setFire are
|
||||
# recorded, so a gap is ambiguous - see docs/env_reference.md
|
||||
# TR_RADAR_SCANLOG log every radar scan tick
|
||||
# TR_RADAR_FORCE_SPIN force the old full-360 spin radar
|
||||
# TR_TRACKER_PROBE dump the enemy-tracker internals
|
||||
#
|
||||
# The VALUE-based switches are the opposite: 0 / false / no / off really
|
||||
# disable them, and anything else enables them. Those are TR_RESULT_LOG,
|
||||
# TR_TMHORIZON_LOG, TR_TMHORIZON_ACCURVE, TR_TMHORIZON_RESET_ON_TARGET,
|
||||
# TR_LEADGAIN_LOG, TR_LEARNED_LOG, TR_LEARNED_GLOBAL, TR_LEARNED_REAL_EVENTS,
|
||||
# TR_POWER_POLICY, TR_POWER_FINISH_KILL, TR_RAM_OPPORTUNITY, TR_RAM_PLAN,
|
||||
# TR_FIRE_FIX, TR_STRAFE_FIRE_FIX, TR_STRAFE_ESCAPE, TR_STRAFE_HEAT_GRID,
|
||||
# TR_TFIL_HEAT_TIME, TR_TFIL_PILLAR_ON, TR_TFIL_DIAG, TR_TFIL_NO_REV,
|
||||
# TR_TFIL_HOLD_WHEN_TRAPPED, TR_TFIL_COMMIT_ARRIVAL,
|
||||
# TR_TFIL_RING_COMMIT_ARRIVAL, GUN_SELECTOR_POOL, GUN_VBULLET_ADMIT_ONLY,
|
||||
# TR_VBULLET_DEBUG, TR_GEO_DEBUG, TR_DEBUG_DRAW, TR_ENV_REPORT.
|
||||
# (TR_TFIL_DIAG / _NO_REV / _HOLD_WHEN_TRAPPED / _COMMIT_ARRIVAL /
|
||||
# _RING_COMMIT_ARRIVAL are read by value in the source; the boot report
|
||||
# labels them by presence, which only affects the "(source: ...)" line, never
|
||||
# the value.)
|
||||
|
||||
# ── movement ─────────────────────────────────────────────────────────────────
|
||||
# Which dodging engine runs: tfil, tfil_ring, strafe, surf or learned.
|
||||
# Which dodging engine runs. VALUE: strafe (default) | tfil | tfil_ring |
|
||||
# surf | learned. Any unrecognised value silently runs `tfil`, with no warning.
|
||||
# WHAT: the engine that picks the dodge tile every tick.
|
||||
# STATUS: DEFAULT = strafe, the measured champion — 300 fresh battles,
|
||||
# +0.30 wins/run over tfil, 95% CI [+0.02, +0.58], sign-flip p = 0.045
|
||||
# (docs/movement_campaign.md, "Fresh-data confirmation (gate v2)").
|
||||
# tfil_ring is measured harmful (round wins 16/49 -> 6/49, p = 0.012).
|
||||
# surf and learned are wired and measured, and neither beats strafe.
|
||||
# GOTCHA: a typo does not warn. `TR_MOVEMENT=straf` runs tfil.
|
||||
# TRY: TR_MOVEMENT=tfil -> the long-shipped engine; the `[env]` block then
|
||||
# reads `move.effective = tfil`. Pairs with the TR_TFIL_* knobs below.
|
||||
TR_MOVEMENT=strafe
|
||||
# Whole-engine on/off switches. 0 removes an engine from the TR_MOVEMENT choices.
|
||||
# Whole-engine on/off switches. VALUE: on | 0/false/no/off. 0 removes an engine
|
||||
# from the TR_MOVEMENT choices; the effective engine then falls back to the
|
||||
# first still-enabled one, and tfil if all are off (there is no "no movement").
|
||||
TR_MODULE_MOVE_TFIL=on # the long-shipped "floor is lava" engine
|
||||
TR_MODULE_MOVE_TFIL_RING=on # the same, re-weighted toward a target range
|
||||
TR_MODULE_MOVE_STRAFE=on # perpendicular strafe with sign-flip reversals
|
||||
@@ -30,7 +198,17 @@ TR_MODULE_MOVE_SURF=on # wave surfing, steered by the GuessFactor
|
||||
TR_MODULE_MOVE_LEARNED=on # learned per-state danger field
|
||||
|
||||
# ── gun rack: which guns the bot may choose (off | 1v1 | melee | both) ───────
|
||||
# The shipped rack is Pattern only. Turn a gun on with `both`.
|
||||
# WHAT: which guns the selector is allowed to fire. The shipped rack is
|
||||
# PATTERN only; every other gun is `off`.
|
||||
# VALUES: off | 1v1 | melee | both. Aliases: any/empty->both, single/lock->1v1,
|
||||
# only1v1->1v1, multi/onlymelee->melee, none/disabled/disable->off.
|
||||
# STATUS: MEASURED — the full 13-gun rack is WORSE than Pattern alone,
|
||||
# p = 0.0012. Do not re-enable guns to "improve" the bot.
|
||||
# GOTCHA: an UNRECOGNISED value warns on stderr and falls back to `both`, i.e.
|
||||
# a typo ADDS the gun back into the rack. It never turns one off.
|
||||
# TRY: TR_RACK_PATTERN=off -> nothing admitted in 1v1; the selector falls
|
||||
# back to the full rack, so do not ship this. Real use is a PAIR:
|
||||
# TR_RACK_PATTERN=off + TR_RACK_HEADON=both -> exactly one gun fires.
|
||||
TR_RACK_PATTERN=both # the only admitted gun; both = usable in 1v1 and melee
|
||||
TR_RACK_HEADON=off # aim straight at the target, no lead
|
||||
TR_RACK_LINEAR=off # constant-angle linear aim
|
||||
@@ -48,16 +226,24 @@ TR_RACK_TMSELECT=off # Tsetlin machine used as the shot selector
|
||||
TR_RACK_TMPATTERN=off # Tsetlin machine used as a pattern matcher
|
||||
TR_RACK_TMHORIZON=off # horizon Tsetlin automata gun
|
||||
TR_RACK_LEADGAIN=off # per-range-band learned lead-gain corrector
|
||||
# Give every admitted gun a fixed share of the turns instead of ranking them,
|
||||
# e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40%. Empty = the ranking selector.
|
||||
# Give every named gun a fixed share of the turns instead of ranking them.
|
||||
# e.g. TR_RACK_SHARE=PATTERN:60%,HEADON:40% (GUN:weight, comma separated,
|
||||
# the % sign is optional). Empty = the ranking selector. GOTCHA: every gun you
|
||||
# name must ALSO be admitted by its own TR_RACK_<GUN> line, or the share is
|
||||
# refused with a loud `[gun_harness] ERROR` and the ranking selector is used
|
||||
# instead. In the shipped rack that means PATTERN and nothing else.
|
||||
TR_RACK_SHARE=
|
||||
# Drop whole guns by rack id (comma separated, e.g. 16). Empty = keep them all.
|
||||
# Ids: 0 HEADON 1 LINEAR 2 TSETLIN 3 CIRCULAR 4 GUESSFACTOR 5 PATTERN
|
||||
# 6 WALLBOUNCE 7 ACCEL 8 STOPSHOT 9 DISPLACE 10 AVGLEAD 11 DECAYGF 12 KNN
|
||||
# 13 TMSELECT 14 TMPATTERN 15 TMHORIZON 16 LEADGAIN. A disabled gun never even
|
||||
# spawns a virtual bullet, so its fitness stays empty and it cannot be picked.
|
||||
GUN_RACK_DISABLE=
|
||||
|
||||
# ── gun selector (which admitted gun fires this tick) ───────────────────────
|
||||
GUN_VBULLET_METRIC=path # fitness measure: path (time-to-collision) or point
|
||||
GUN_SELECTOR_MODE=relative # rank guns against the incumbent (absolute = vs a fixed bar)
|
||||
GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness
|
||||
GUN_SELECTOR_WINDOW=100 # ticks of virtual-bullet history behind the fitness (clamped 1..100)
|
||||
GUN_SELECTOR_MINOBS=50 # observations a gun needs before it may compete
|
||||
GUN_SELECTOR_TIE=0.2 # relative margin two guns must differ by to count as separated
|
||||
GUN_SELECTOR_FLOOR=0.25 # fitness fraction of the peak below which a band is unsafe
|
||||
@@ -73,6 +259,8 @@ GUN_SELECTOR_SEED=
|
||||
|
||||
# ── power / energy policy ────────────────────────────────────────────────────
|
||||
# Every rule below only ever CAPS power; the gun's own preference is the ceiling.
|
||||
# The measured case for keeping it on: TR_POWER_POLICY=0 drops the real hit rate
|
||||
# from 10.61% to 7.88%, p = 0.0012.
|
||||
TR_POWER_POLICY=on # 0 = no cap at all (the control arm)
|
||||
TR_POWER_FAR_DIST=200.0 # px; past this the enemy is in the bad-chances zone
|
||||
TR_POWER_FAR_CAP=1.0 # cap applied past TR_POWER_FAR_DIST
|
||||
@@ -85,7 +273,7 @@ TR_POWER_ENERGY_MAX=3.0 # the cap at/above ENERGY_HI; 3.0 means effectively unc
|
||||
TR_POWER_FINISH_KILL=on # cap to the smallest bullet that still kills a low-energy enemy
|
||||
|
||||
# ── radar ────────────────────────────────────────────────────────────────────
|
||||
#TR_RADAR_FORCE_SPIN=1 # presence-only: force the old full 360 spin instead of 1v1 lock
|
||||
#TR_RADAR_FORCE_SPIN=1 # PRESENCE-only: force the old full 360 spin instead of 1v1 lock
|
||||
TR_RADAR_SCAN_LOG_PATH=/tmp/radar_scan_log.jsonl # where the per-tick scan log is written
|
||||
|
||||
# ── ram ──────────────────────────────────────────────────────────────────────
|
||||
@@ -98,30 +286,274 @@ TR_RAM_PLAN=off # the change-of-plan trigger (enemy outguns us while
|
||||
TR_RAM_PLAN_DIST=250.0 # px; max range at which the plan trigger may fire
|
||||
TR_RAM_PLAN_MARGIN=20.0 # energy advantage the plan trigger needs
|
||||
TR_RAM_PLAN_HITRATE=0.05 # pooled virtual hit rate below which the gun duel counts as failing
|
||||
# WHAT: at or below this much SELF energy we start no new shot, keeping a
|
||||
# reserve for the ram. Units: energy points.
|
||||
# VALUES: energy, 0.0 = off (today's behaviour). Any float parses.
|
||||
# STATUS: =5 is a CLEAN NULL and must not be re-run: -0.018 wins/run,
|
||||
# p(sign-flip) = 0.7676, under a 0.1420 wins/run MDE, 900 battles. The
|
||||
# mechanism fired on 0.04% of ticks, ~200x less than the offline ruler
|
||||
# predicted. Docs: docs/ram_floor_exhaustion_ab.md.
|
||||
# GOTCHA: 0.0 genuinely disables it, but any POSITIVE value arms it, and the
|
||||
# higher it goes the more of the low-energy zone it blocks (20 blocked 24.7%
|
||||
# of all ticks offline).
|
||||
# TRY: TR_RAM_FLOOR_ENERGY=5 -> already measured, do not re-run. If you
|
||||
# want to see it at all, that is the only defensible value; anything
|
||||
# higher is worse by the offline ruler and by the live null.
|
||||
TR_RAM_FLOOR_ENERGY=0.0
|
||||
# WHAT: the last-scanned enemy energy at or below this switches the ram decider
|
||||
# into exhaustion mode (they are out of ammo, we are not). Units: energy.
|
||||
# VALUES: energy, 0.0 = off. Any float parses.
|
||||
# STATUS: DEFAULT-OFF, never live-tested. It is the second half of the j160 pair;
|
||||
# the other half (FLOOR_ENERGY) measured null, so the pair is not a win.
|
||||
# GOTCHA: 0.0 is the only safe "off". The always-on finisher (enemy < 20 energy
|
||||
# and we are healthier, within 300 px) already covers most of this; setting
|
||||
# this to 20 makes the two overlap.
|
||||
# TRY: TR_RAM_ENEMY_ENERGY=10 -> ram once a scan shows them at <= 10.
|
||||
# Watch: the `[ram] ON rrExhausted` line. Good: the reason field says
|
||||
# `exhausted` rather than `finisher`. Needs TR_RAM_LOG=1 to see it.
|
||||
TR_RAM_ENEMY_ENERGY=0.0
|
||||
|
||||
# ── movement internals: tfil (the floor-is-lava field) ──────────────────────
|
||||
TR_TFIL_RANGE_LO=100.0 # px; lower edge of the range band the ring mover prefers
|
||||
TR_TFIL_RANGE_HI=200.0 # px; upper edge of that band
|
||||
TR_TFIL_RANGE_TEMP=0.4 # sharpness of the ring mover's weighted random draw
|
||||
TR_TFIL_RANGE_K=60.0 # px; how fast the weight falls off outside the band
|
||||
# WHAT (tfil_ring only): hold the committed dodge tile until we are actually ON
|
||||
# it, instead of the fixed dwell. VALUE: on | 0/off.
|
||||
# STATUS: DEFAULT-OFF, never live-tested. Ported to the ring fork in j165; the
|
||||
# tfil original (TR_TFIL_COMMIT_ARRIVAL) has a real but under-powered live
|
||||
# result — see that line. Neither is a proven win.
|
||||
# GOTCHA: the ring mover is NOT the default and is not shippable (round wins
|
||||
# 16/49 -> 6/49, p = 0.012), so this only matters while you are measuring it.
|
||||
# TRY: TR_MOVEMENT=tfil_ring + TR_TFIL_RING_COMMIT_ARRIVAL=1
|
||||
# -> the `[env]` block reads TR_TFIL_RING_COMMIT_ARRIVAL = on.
|
||||
TR_TFIL_RING_COMMIT_ARRIVAL=off
|
||||
# WHAT (tfil_ring only): below this self speed (px/tick), a mid-flight switch
|
||||
# may not turn the bot around. UNITS: px/tick (top speed is 8).
|
||||
# STATUS: DEFAULT-OFF (0.0), never live-tested. The tfil original is in the
|
||||
# j144 recommendation below.
|
||||
# TRY: TR_TFIL_RING_NOREV_SPEED=4 -> 4 px/tick is half of top speed, the
|
||||
# value j144 used on tfil. Accepts any float >= 0.
|
||||
TR_TFIL_RING_NOREV_SPEED=0.0
|
||||
TR_TFIL_CORRIDOR_HEAT=10.0 # lava painted per corridor-overlapping tile
|
||||
# WHAT: cap the bullet-danger corridor at `bullet speed x this many ticks`,
|
||||
# instead of running it all the way to the arena wall. UNITS: ticks.
|
||||
# VALUES: ticks, 0.0 = off (corridor reaches the wall, today's behaviour).
|
||||
# Any float parses; negatives are treated as 0.
|
||||
# STATUS: DEFAULT-OFF, never live-tested, and NOT recommended. Commit 5e213df
|
||||
# (j148) shipped it with no measurement at all: no battle, no offline ruler.
|
||||
# The neighbouring j146 field-shape sweep, which is the closest evidence,
|
||||
# says halving the corridor restores a safe tile set offline (filter-broken
|
||||
# 63.5% -> 30.4%) and is a LIVE NULL on outcome.
|
||||
# GOTCHA: value knob - 0.0 genuinely disables it. It is NOT a presence knob.
|
||||
# TRY: TR_TFIL_CORRIDOR_TICKS=20 -> a 20-tick look-ahead. Bullet speed is
|
||||
# 20 - 3*power, so over the shipped power bins 1.0..3.0 that is
|
||||
# 17.0..11.0 px/tick = 340..220 px of corridor, instead of the whole
|
||||
# wall. Watch: in the debug overlay the corridor stops short of the
|
||||
# wall. Only bind it to tfil; strafe has its own knob below.
|
||||
TR_TFIL_CORRIDOR_TICKS=0.0
|
||||
TR_TFIL_WALL_HOTNESS=15.0 # peak heat painted on tiles next to a wall
|
||||
TR_TFIL_WALL_RADIANCE=10.0 # how fast wall heat falls off with distance
|
||||
TR_TFIL_BULLET_CORE=10.0 # tfil only: lava per bullet-overlapping tile (== PathDangerThreshold, so a bullet is never hot on its own)
|
||||
TR_TFIL_BULLET_AURA=5.0 # tfil only: lava for the bullet's aura ring tiles
|
||||
TR_TFIL_TILE_REPLAN=self # self | enemy | off: when a dodge commitment is cancelled
|
||||
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning
|
||||
TR_TFIL_NO_REV=off # on = never reverse direction inside a corridor
|
||||
# WHAT: how hot a bullet paints the tile it is sitting on. UNITS: lava points
|
||||
# on the picker's heat scale.
|
||||
# VALUES: any float >= 0. 10.0 is exactly the danger threshold, so a bullet is
|
||||
# never dangerous on its own; 20.0 puts one bullet's own tile over it.
|
||||
# STATUS: live-tested ONLY as part of the j146 five-shape batch, 375 battles:
|
||||
# core 10 -> 20 was a live NULL on both primaries, and the arm that combined
|
||||
# it with no corridor/wall field LOST 13.45 damage/run, p(sign-flip) =
|
||||
# 0.0095. The middle shape (corridor 10 / wall 15/5 / core 20 / aura 10) is
|
||||
# also a null. Do not retune the shape on one axis.
|
||||
# GOTCHA: changing CORE alone with the shipped corridor (10) and wall (15) is
|
||||
# the one combination j146 did NOT isolate.
|
||||
# TRY: TR_TFIL_BULLET_CORE=20 + TR_TFIL_BULLET_AURA=10 -> the j146
|
||||
# "middle" bullet heat. Only meaningful as part of the whole middle
|
||||
# shape; on its own it is a null at best.
|
||||
TR_TFIL_BULLET_CORE=10.0
|
||||
# WHAT: the heat painted on the bullet's AURA ring (the tiles around it), as
|
||||
# opposed to the core tile. UNITS: lava points.
|
||||
# VALUES: any float >= 0; 5.0 is half the core's 10.0.
|
||||
# STATUS: live-tested only inside the j146 batch — null, see BULLET_CORE.
|
||||
# TRY: TR_TFIL_BULLET_AURA=10 -> doubles the aura heat; pairs with
|
||||
# TR_TFIL_BULLET_CORE=20 in the j146 "middle" shape.
|
||||
TR_TFIL_BULLET_AURA=5.0
|
||||
# WHAT: when a dodge commitment is cancelled, replan from whose state?
|
||||
# self = today's behaviour. VALUE: self | enemy | off.
|
||||
# STATUS: `self` is the shipped default; `off` is the pre-j144 behaviour and is
|
||||
# exactly what an earlier A/B (cc11ede arm D) tried and could not measure.
|
||||
# TRY: TR_TFIL_TILE_REPLAN=off -> the tile-crossing cancel stops firing.
|
||||
# Accepts self, enemy, off, none, never, 0, false (case-insensitive);
|
||||
# anything else falls back to self with no warning.
|
||||
TR_TFIL_TILE_REPLAN=self
|
||||
TR_TFIL_COMMIT_TICKS=15 # ticks to commit to a dodge point before replanning (min 1)
|
||||
# WHAT: hold the committed dodge tile until we are actually ON it, instead of
|
||||
# letting our own tile-boundary crossing cancel it. VALUE: on | 0/off.
|
||||
# STATUS: LIVE, 600 battles in two blocks (j144). Mechanism is real and
|
||||
# confirmed: incoming hit rate 18.07% -> 14.92%, sign-flip p = 0.0013, damage
|
||||
# taken -24.57/run. Outcome is NOT distinguishable: +0.28 wins/run,
|
||||
# p(sign) = 0.0574 against a 0.31 MDE. The ledger's answer for your own .env
|
||||
# is YES to this knob, and NO to COMMIT_MARGIN.
|
||||
# GOTCHA: only meaningful with TR_MOVEMENT=tfil; the default strafe mover has
|
||||
# no tfil commitment. With TR_TFIL_COMMIT_ARRIVAL on, COMMIT_TICKS becomes a
|
||||
# MINIMUM dwell, not a maximum.
|
||||
# TRY: TR_MOVEMENT=tfil + TR_TFIL_COMMIT_ARRIVAL=1
|
||||
# -> `[env] TR_TFIL_COMMIT_ARRIVAL = on (source: .env)`.
|
||||
TR_TFIL_COMMIT_ARRIVAL=off
|
||||
# WHAT: leave the committed tile only if the best alternative is at least this
|
||||
# much COOLER on the same pathMaxHeat scale the picker uses. UNITS: lava
|
||||
# points; 10.0 is exactly one PathDangerThreshold level.
|
||||
# VALUES: any float >= 0; 0.0 = off = today's behaviour (any improvement ends
|
||||
# the commitment).
|
||||
# STATUS: LIVE, j144, 600 battles, as the `arrive_hyst` arm. It is the WEAKEST
|
||||
# of the three j144 arms on both primaries (+0.19 wins/run, p = 0.092) and
|
||||
# the ledger's explicit answer is: YES to COMMIT_ARRIVAL, NO to this.
|
||||
# GOTCHA: it is a hysteresis, so it makes the bot commit harder; a large value
|
||||
# with a busy field means it holds a tile that is no longer the best one.
|
||||
# TRY: TR_TFIL_COMMIT_MARGIN=10 -> the j144 value. Already measured, and
|
||||
# the answer was no. Use it only to isolate COMMIT_ARRIVAL, not as an
|
||||
# improvement.
|
||||
TR_TFIL_COMMIT_MARGIN=0.0
|
||||
# WHAT: refuse a candidate tile we cannot REACH inside this many ticks. The
|
||||
# bot's top speed is 8 px/tick, so 1 tick = 8 px. UNITS: ticks.
|
||||
# VALUES: ticks, 0.0 = off = today's uniform draw over every safe tile.
|
||||
# STATUS: DEFAULT-OFF, never live-tested. Found by the j151 offline ruler: 65%
|
||||
# of tfil picks outran the 15-tick commitment and the chosen tile was reached
|
||||
# only 6.5% of the time.
|
||||
# GOTCHA: it is a HARD bound, not a preference. If every safe tile is out of
|
||||
# range the pool empties and the code falls back to today's full pool, so it
|
||||
# can never starve the draw — it can also silently do nothing.
|
||||
# TRY: TR_TFIL_ARRIVE_TICKS=15 -> refuse anything farther than 15*8 = 120
|
||||
# px. That is deliberately the same length as TR_TFIL_COMMIT_TICKS,
|
||||
# i.e. "only pick a tile you can still reach while you hold it".
|
||||
TR_TFIL_ARRIVE_TICKS=0.0
|
||||
# WHAT: when the safe-tile set is EMPTY (no tile under the heat threshold),
|
||||
# hold position for one tick instead of promoting the 2 least-hot blocked
|
||||
# tiles. VALUE: on | 0/off.
|
||||
# STATUS: DEFAULT-OFF, never live-tested. j153 wrote the proposal and the A/B
|
||||
# design; it was NOT run (docs/tfil_hold_when_trapped_ab.md is titled
|
||||
# "NOT RUN"). Four mechanism-positive / outcome-null results preceded it, so
|
||||
# a null was always the likely answer.
|
||||
# GOTCHA: it can never latch — the pick site only runs on a replan tick, so the
|
||||
# next tick re-reads the field from scratch. The gun is untouched: a held
|
||||
# tick still fires exactly like every other tick.
|
||||
# TRY: TR_TFIL_HOLD_WHEN_TRAPPED=on -> the tfil pick log shows `hold`
|
||||
# instead of `promote` on a trapped tick. Needs TR_TFIL_COMMIT_LOG set.
|
||||
TR_TFIL_HOLD_WHEN_TRAPPED=off
|
||||
# WHAT: the BOUNDED version of the knob above: while the safe set stays empty,
|
||||
# hold for at most this many ticks per empty streak. UNITS: ticks (integer).
|
||||
# VALUES: integer >= 0; 0 = off = today's promote-the-2 fallback.
|
||||
# STATUS: DEFAULT-OFF, never live-tested (j154, no battle).
|
||||
# GOTCHA: the budget is DERIVED, not guessed: the enemy fires two 3.0-power
|
||||
# shots 16 ticks apart, so 16 is the first window that admits its second
|
||||
# shot. The hold is released the tick a safe tile exists, the counter resets
|
||||
# when one is taken, and a tracked bullet reaching us within min(N,16) ticks
|
||||
# overrides the hold outright (panic release).
|
||||
# TRY: TR_TFIL_HOLD_MAX_TICKS=16 -> the derived budget. Any integer parses;
|
||||
# a junk value degrades to 0 (off).
|
||||
TR_TFIL_HOLD_MAX_TICKS=0
|
||||
# WHAT: the hard heat filter on a candidate's path. UNITS: lava points.
|
||||
# VALUES: any float >= 0. Lava is QUANTISED to 5, so the only values that
|
||||
# change anything are 10, 15 and 20: 10-14 admits exactly what 10 admits.
|
||||
# STATUS: 10.0 is the shipped const; j150 made it sweepable so the offline
|
||||
# ruler could move it. Never live-tested as a knob.
|
||||
# GOTCHA: raising it does not make the bot braver, it makes the safe set
|
||||
# smaller and more of the picks forced. j146's `nofield` arm is the warning:
|
||||
# -13.45 damage/run, p = 0.0095.
|
||||
# TRY: TR_TFIL_DANGER_THRESHOLD=15 -> one quantisation step stricter. The
|
||||
# picker log then reports fewer safe candidates per pick.
|
||||
TR_TFIL_DANGER_THRESHOLD=10.0
|
||||
# WHAT: fill the per-pick LOSS HISTOGRAM (TfilLoss*): how many tiles die at
|
||||
# each picker stage. VALUE: on | 0/off (read by value, not by presence).
|
||||
# STATUS: DIAGNOSTIC ONLY, j150. Provably does not move a single move command
|
||||
# (byte-for-byte, guard-tested). Never measured on outcome, by design.
|
||||
# GOTCHA: nothing. It is the safest tfil knob in this file.
|
||||
# TRY: TR_TFIL_DIAG=on -> the tfil pick log gains the per-stage counts.
|
||||
TR_TFIL_DIAG=off
|
||||
# WHAT: shape the tile DRAW over the heat-filtered pool by geometry (how far
|
||||
# the tile sits from where we are already going) as well as by heat.
|
||||
# VALUES: off | turn | dist | both, each optionally suffixed -soft (default),
|
||||
# -topk or -rej. `distance`=dist, `rejection`=rej are also accepted.
|
||||
# Anything unrecognised, and `off`, means OFF — today's uniform draw.
|
||||
# The form is NOT printed by the boot report, only the dim.
|
||||
# STATUS: DEFAULT-OFF. THE ONE LIVE-TESTED ARM IS `both-rej` + TAU=60 AND IT
|
||||
# WAS REJECTED: -8.83 damage/run, p(sign-flip) = 0.0061, Wilcoxon p = 0.011,
|
||||
# 420 battles, 15 opponents. Round wins null. Offline it did exactly what was
|
||||
# predicted (arrivals 4.5% -> 29.4%) and that is WHY it is bad: the bot ends
|
||||
# up 26 px further out on 15/15 opponents and deals less. See
|
||||
# docs/tfil_geo_ab.md. DO NOT re-run both-rej.
|
||||
# GOTCHA: this knob ALONE is inert — TR_TFIL_GEO_TAU=0.0 means the weighting is
|
||||
# off whatever the mode says. And it needs TR_MOVEMENT=tfil.
|
||||
# TRY: TR_TFIL_GEO_MODE=both-soft + TR_TFIL_GEO_TAU=45
|
||||
# -> the j152 offline headline (arrivals 4.5% -> 16.9%, top-tile share
|
||||
# only 7.0% -> 8.6%). Still never live-tested, and the family has one
|
||||
# measured loss, so treat it as a hypothesis.
|
||||
TR_TFIL_GEO_MODE=off
|
||||
# WHAT: the geometric cost scale, in degrees. 0.0 = off, i.e. exactly today's
|
||||
# uniform draw. UNITS: degrees. VALUES: any float >= 0.
|
||||
# STATUS: never live-tested with a mode other than off. The one live arm used
|
||||
# TAU=60 and lost (see GEO_MODE).
|
||||
# GOTCHA: TAU is IGNORED unless GEO_MODE is not off. A big TAU with mode=off
|
||||
# looks like it is doing something and is not.
|
||||
# TRY: TR_TFIL_GEO_TAU=45 -> 45 degrees of turn cost. Pairs with
|
||||
# TR_TFIL_GEO_MODE=both-soft; on its own it changes nothing.
|
||||
TR_TFIL_GEO_TAU=0.0
|
||||
# WHAT: inside a corridor, never pick a tile in the reverse direction. VALUE:
|
||||
# on | 0/false/no/off (read by value, so `=off` really disables it).
|
||||
# STATUS: never live-tested as a standalone arm. The j144/j145 arms relied on
|
||||
# the NOREV_SPEED knob instead, which is stricter.
|
||||
# GOTCHA: soft only — every weight is floored, so the pool can never empty; and
|
||||
# it overlaps TR_TFIL_NOREV_SPEED, which is the knob that was actually run.
|
||||
# TRY: TR_TFIL_NO_REV=on -> a 3:1 forward:rearward draw weight inside a
|
||||
# corridor. Accepts on/1/true/yes to enable, 0/false/no/off to disable.
|
||||
TR_TFIL_NO_REV=off
|
||||
TR_TFIL_COMMIT_LOG= # path for the per-commit log; empty = no log
|
||||
TR_TFIL_COMMIT_ARRIVAL=off # on = hold the dodge tile until we are ON it (not a fixed dwell)
|
||||
TR_TFIL_COMMIT_MARGIN=0.0 # lava an alternative tile must be cooler by before it wins the tile
|
||||
TR_TFIL_NOREV_SPEED=0.0 # px/tick; below this, a mid-flight switch may not turn the bot around
|
||||
TR_TFIL_TURN_BIAS=0.0 # turn TIEBREAK odds ratio among SAFE tiles; 0 = uniform draw as today
|
||||
TR_TFIL_TURN_REF_DEG=45.0 # deg; turn below which the tiebreak applies no penalty
|
||||
# WHAT: while our own speed is below this, a mid-flight target switch may not
|
||||
# take a tile more than 90 degrees off the travel direction. UNITS: px/tick
|
||||
# (top speed 8). VALUES: any float >= 0; 0.0 = off = shipped.
|
||||
# STATUS: LIVE, j144/j145, 600 battles. Mechanism confirmed: slow opposite-way
|
||||
# mid-flight switches fell 394 -> 64 offline. Outcome not distinguishable on
|
||||
# its own (+0.12 wins/run, p = 0.39); the ledger recommends 4 in your .env
|
||||
# together with COMMIT_ARRIVAL, not alone.
|
||||
# GOTCHA: the pool can never be emptied — with every candidate behind us it
|
||||
# takes the least-bad turn. 0.0 genuinely disables it.
|
||||
# TRY: TR_TFIL_NOREV_SPEED=4 -> 4 px/tick = half of top speed, the j144
|
||||
# value. Watch: fewer >90 deg switches in the tfil pick log.
|
||||
TR_TFIL_NOREV_SPEED=0.0
|
||||
# WHAT: turn TIEBREAK odds ratio among the SAFE tiles: a straight-ahead safe
|
||||
# tile is drawn `1 + bias` times as often as a 180-degree one.
|
||||
# w = max(1, round(1 + BIAS * (1 - max(0,|turn| - REF)/180)))
|
||||
# UNITS: dimensionless. VALUES: any float >= 0; 0.0 = uniform draw as today.
|
||||
# STATUS: LIVE NULL, j145, 300 battles: +0.15 wins/run, p(sign) = 0.244, under
|
||||
# a 0.30 MDE. Docs: docs/movement_campaign.md. A real but small mechanism
|
||||
# with an under-powered outcome.
|
||||
# GOTCHA: the turn cost is NEVER folded into the heat score — the filter stays
|
||||
# hard, and every weight is floored at 1, so the pool can never empty.
|
||||
# TRY: TR_TFIL_TURN_BIAS=9 + TR_TFIL_TURN_REF_DEG=0
|
||||
# -> the j145 arm value (the knee of the offline bias curve: mean
|
||||
# |turn| -11%, opposite picks -22%). Already measured null.
|
||||
TR_TFIL_TURN_BIAS=0.0
|
||||
# WHAT: the turn below which the tiebreak above applies NO penalty. UNITS:
|
||||
# degrees. VALUES: any float >= 0; 45.0 = today's default.
|
||||
# STATUS: inert unless TR_TFIL_TURN_BIAS > 0. j145 used 0 with bias 9.
|
||||
# GOTCHA: on its own this knob does nothing at all.
|
||||
# TRY: TR_TFIL_TURN_REF_DEG=0 -> penalise every turn, not just the sharp
|
||||
# ones. Pairs with TR_TFIL_TURN_BIAS=9; alone it is a no-op.
|
||||
TR_TFIL_TURN_REF_DEG=45.0
|
||||
# WHAT: paint heat on the virtual centre pillar. VALUE: on | 0/off. The shipped
|
||||
# field has NO pillar: PillarHotness/PillarRadiance are 0.0.
|
||||
# STATUS: live-tested, and the OWNER OVERRULED the recommendation to turn it
|
||||
# back on: `old` (pillar on) was best on damage/run (287) and round wins
|
||||
# (35/70) but the contrast is INSIDE the MDE (33 damage/run, 1.22 wins/run
|
||||
# at n=10) and damage taken was 30.8/run higher with the pillar off
|
||||
# (p = 0.040, not corrected for multiple arms). Decision: pillar stays
|
||||
# removed. Docs: docs/tfil_heat_pillar_ab.md.
|
||||
# GOTCHA: this restores an INVENTED hazard with no physical object behind it.
|
||||
# Reversing that decision needs its own pre-registered A/B.
|
||||
# TRY: TR_TFIL_PILLAR_ON=1 -> the pre-change field (30/10), for a fair
|
||||
# A/B against the shipped one. Needs TR_MOVEMENT=tfil.
|
||||
TR_TFIL_PILLAR_ON=off
|
||||
TR_TFIL_HEAT_TIME=off # on = index bullet heat by time (flat field when off)
|
||||
TR_TFIL_HEAT_TAU=9.0 # ticks a tracked bullet's heat lives for
|
||||
TR_TFIL_HEAT_POWER_GAIN=1.0 # scale of the heat a bullet paints, per firepower
|
||||
TR_TFIL_PILLAR_ON=off # on = paint heat on the arena centre, which has no pillar
|
||||
|
||||
# ── movement internals: strafe ───────────────────────────────────────────────
|
||||
TR_STRAFE_BAND=20.0 # degrees the heading may sit off the perpendicular
|
||||
@@ -140,12 +572,42 @@ TR_STRAFE_WALL_BIAS=0.35 # how strongly a tile farther from the wall is preferr
|
||||
TR_STRAFE_WALL_SAFE=24.0 # px; a wing point never lands nearer than this to a wall
|
||||
TR_STRAFE_ESCAPE=on # the guaranteed wall escape when every candidate is hot
|
||||
TR_STRAFE_FIRE_FIX=on # strafe's share of the shared TR_FIRE_FIX switch
|
||||
TR_FIRE_FIX=on # 0 = the shipped previous-energy bullet detector
|
||||
#TR_FIRE_DIAG=1 # presence-only: per-reading tick/raw/correction trace
|
||||
# WHAT: the shared enemy-fire detector. VALUE: on | 0/off. One switch, read by
|
||||
# every mover; each mover may AND it with its own (TR_STRAFE_FIRE_FIX).
|
||||
# STATUS: j134 propagated it to all five movers; the per-mover catch table went
|
||||
# 98.888% -> 100% of enemy fires on a 70-battle corpus.
|
||||
# GOTCHA: TR_STRAFE_FIRE_FIX is an AND, so turning TR_FIRE_FIX off is enough;
|
||||
# turning only TR_STRAFE_FIRE_FIX off does not disable strafe's detector.
|
||||
# TRY: TR_FIRE_FIX=0 -> the shipped previous-energy detector everywhere
|
||||
# (the control arm for any fire-detector A/B).
|
||||
TR_FIRE_FIX=on
|
||||
#TR_FIRE_DIAG=1 # PRESENCE-only: per-reading tick/raw/correction trace
|
||||
# WHAT: back-date every detected enemy fire by this many ticks when the ghost
|
||||
# is spawned. UNITS: ticks (integer, clamped at 0). 0 = shipped.
|
||||
# STATUS: LIVE, j147, 180 battles. The 1-tick detection lag is OURS and was
|
||||
# measured on 1777 matched ghost spawns (displacement 19.1 px mean on tfil,
|
||||
# 16.1 on strafe; the arrival deadline the mover reads was 0.99 / 0.77 ticks
|
||||
# late). With =1 it falls to 5.4 / 9.0 px and 0.06 ticks. The live OUTCOME is
|
||||
# null on both movers (+2.08 / +3.77 damage/run, every p > 0.6), so it stays 0.
|
||||
# GOTCHA: a junk or negative value degrades to 0, never a negative back-date.
|
||||
# TRY: TR_FIRE_LAG=1 -> one bullet step at power 1.0 is 17 px; the ghost is
|
||||
# born that far downrange. Watch the tfil/strafe pick log's arrival
|
||||
# deadline, which is what the knob actually corrects.
|
||||
TR_FIRE_LAG=0
|
||||
TR_STRAFE_HEAT_GRID=on # draw the whole heat grid; 0 leaves only the chosen tile
|
||||
TR_STRAFE_BULLET_CORE=20.0 # strafe's own retune: lava per bullet-overlapping tile
|
||||
TR_STRAFE_BULLET_AURA=10.0 # strafe's own retune: lava for the bullet aura ring
|
||||
TR_STRAFE_CORRIDOR_HEAT=10.0 # strafe's own retune: lava per corridor tile
|
||||
# WHAT: the same corridor LENGTH bound as TR_TFIL_CORRIDOR_TICKS, for the
|
||||
# strafe mover. UNITS: ticks. VALUES: ticks, 0.0 = to the wall (shipped).
|
||||
# STATUS: DEFAULT-OFF, never live-tested (commit 5e213df, j148). Same standing
|
||||
# as the tfil one: no battle, no offline ruler, not recommended.
|
||||
# GOTCHA: this is the DEFAULT engine's knob. Setting only the tfil one does
|
||||
# nothing at all, because the shipped movement is strafe.
|
||||
# TRY: TR_STRAFE_CORRIDOR_TICKS=20 -> 20-tick look-ahead, 220..340 px over
|
||||
# the shipped power bins, instead of the whole wall. Watch: the strafe
|
||||
# heat grid's corridor stops before the wall.
|
||||
TR_STRAFE_CORRIDOR_TICKS=0.0
|
||||
TR_STRAFE_WALL_HOTNESS=15.0 # strafe's own retune: peak wall heat
|
||||
TR_STRAFE_WALL_RADIANCE=5.0 # strafe's own retune: wall heat falloff
|
||||
|
||||
@@ -154,7 +616,7 @@ TR_SURF_PREF_DIST=400.0 # px; the wave distance the mover tries to sit at
|
||||
TR_SURF_DIST_BAND=50.0 # px dead band around it
|
||||
TR_SURF_WALL_MARGIN=48.0 # px kept from the wall when picking a wave point
|
||||
TR_SURF_RADIAL_FRAC=0.35 # how much of the remaining weight goes to the radial blend
|
||||
#TR_SURF_LOG=1 # presence-only: one line per wave-surfing decision
|
||||
#TR_SURF_LOG=1 # PRESENCE-only: one line per wave-surfing decision
|
||||
|
||||
# ── movement internals: learned (per-state learned danger) ──────────────────
|
||||
TR_LEARNED_DECAY_EVERY=128 # learns between forgetting passes; 0 never forgets
|
||||
@@ -169,18 +631,20 @@ TR_LEARNED_WALL_MARGIN=48.0 # px kept from the wall
|
||||
TR_LEARNED_GLOBAL=off # on = ignore the learned state (ablation arm)
|
||||
TR_LEARNED_LABEL=histogram # histogram (default) or outcome: what a wave is labelled with
|
||||
TR_LEARNED_REAL_EVENTS=off # on = resolve a wave on the real bullet event, not on energy
|
||||
#TR_LEARNED_LOG=1 # presence-only: one line per learned decision
|
||||
#TR_LEARNED_LOG=1 # VALUE-based (1/on/yes to enable, 0/off to disable)
|
||||
|
||||
# ── guns ─────────────────────────────────────────────────────────────────────
|
||||
# Virtual bullets: the prediction the whole gun selector is built on.
|
||||
TR_MODULE_VBULLETS=on # 0 = no gun predicts or spawns; the selector falls back to its floor gun
|
||||
|
||||
# TMH — the horizon Tsetlin automata gun.
|
||||
# TMH — the horizon Tsetlin automata gun. Inert unless TR_RACK_TMHORIZON=both.
|
||||
TR_TMHORIZON_SHIFT=2.0 # degrees added to the aim; 0 disables the correction arm
|
||||
TR_TMHORIZON_BIG_MULT=1.5 # extra scale applied when the error magnitude is big
|
||||
TR_TMHORIZON_RESET_ON_TARGET=on # wipe the automata when the target changes
|
||||
TR_TMHORIZON_NSTATES=64 # automata state count (the inertia it can hold)
|
||||
TR_TMHORIZON_WINDOW=0 # samples kept in the sliding window; 0 keeps everything
|
||||
# e.g. TR_TMHORIZON_WINDOW=30 -> retrain on the 30 most recent samples only.
|
||||
# MEASURED HARMFUL LIVE at 150: 26.5% round wins vs 49.0%, p = 0.036. Keep 0.
|
||||
TR_TMHORIZON_RESET_DROP=0.0 # rolling accuracy drop, in points, that forces a retrain
|
||||
TR_TMHORIZON_ACCURVE=off # log the accuracy curve even without the thinking log
|
||||
TR_TMHORIZON_RETRAIN_EVERY=50 # samples between full retrains in sliding mode
|
||||
@@ -193,7 +657,7 @@ TR_LEADGAIN_MIN_OBS=8 # samples a band needs before its gain is trusted
|
||||
TR_LEADGAIN_DECAY=250 # samples between count-decay passes
|
||||
TR_LEADGAIN_DECAY_FRAC=0.02 # fraction each decay pass takes off every count
|
||||
TR_LEADGAIN_RESET_ON_TARGET=on # wipe the learned gains when the target changes
|
||||
TR_LEADGAIN_LOG=off # on = one line per gain change
|
||||
TR_LEADGAIN_LOG=off # on = one line per gain change (value-based: 0 disables)
|
||||
# Kept only so a pre-rename .env does not warn. The gun does not read them.
|
||||
TR_LEADGAIN_N=32 # NO-OP: the old SBC geometry, no longer used
|
||||
TR_LEADGAIN_NADE=256 # NO-OP: the old ADE count, no longer used
|
||||
@@ -207,6 +671,10 @@ TR_LEADGAIN_SEED=20240921 # NO-OP: the old seed, no longer used
|
||||
TR_PATTERN_LEN=10 # ticks of movement history used as the search key
|
||||
TR_PATTERN_DEPTH=500 # how far back the history scan may reach
|
||||
TR_PATTERN_RAD_OFFSET=0.0 # px added to the aim distance; negative aims short
|
||||
# Both RAD_* knobs are read LAZILY, inside predict() — the one place a
|
||||
# mid-run change can matter, and it still does not. The live aim is bearing
|
||||
# only, so a purely radial offset is structurally invisible: MEASURED
|
||||
# byte-identical on bmPath. Docs: docs/env_reference.md §7.
|
||||
TR_PATTERN_RAD_SCALE=1.0 # multiplier on the whole aim distance
|
||||
|
||||
# The SBC library (common_libs/bitbrain), not a gun knob. Registered so a config
|
||||
@@ -236,32 +704,71 @@ TR_BITBRAIN_DECAY_SHIFT=1 # forgetting strength, counted mode only; 0 disa
|
||||
#TR_BITBRAIN_RESET_ON_TARGET=on # LEGACY: old name of TR_LEADGAIN_RESET_ON_TARGET
|
||||
|
||||
# ── debug overlays (all on top of the gun; they never change a decision) ─────
|
||||
TR_DEBUG_DRAW=on # master switch for every mover's debugGraphics
|
||||
TR_GEO_DEBUG=off # draw the shared candidate-tile geometry overlay
|
||||
TR_VBULLET_DEBUG=off # draw each admitted gun's virtual-bullet paths
|
||||
TR_VBULLET_DEBUG_GUN= # which gun the overlay draws: empty = the selected one
|
||||
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun
|
||||
# WHAT: master switch for every mover's debugGraphics. VALUE: on | 0/off.
|
||||
TR_DEBUG_DRAW=on
|
||||
# WHAT: draw the shared candidate-tile geometry overlay. VALUE: on | 0/off.
|
||||
# DRAW ONLY — it cannot change a decision, so it is the safest knob here.
|
||||
# GOTCHA: independent of TR_DEBUG_DRAW: the overlay is drawn either way, and
|
||||
# TR_DEBUG_DRAW=0 is what suppresses the movers' own graphics.
|
||||
# TRY: TR_GEO_DEBUG=on -> the geometry circles appear over the arena.
|
||||
TR_GEO_DEBUG=off
|
||||
# WHAT: draw each admitted gun's virtual-bullet paths. VALUE: 1/on/yes to
|
||||
# enable, 0/off/no/false to disable, unset = off. DRAW ONLY.
|
||||
# GOTCHA: needs a gun in the rack to be legible; the shipped rack admits
|
||||
# Pattern only, so set TR_VBULLET_DEBUG_GUN too.
|
||||
# TRY: TR_VBULLET_DEBUG=1 + TR_VBULLET_DEBUG_GUN=all
|
||||
# -> travelled path, aim ring and miss vector for every admitted gun.
|
||||
TR_VBULLET_DEBUG=off
|
||||
# WHICH gun the overlay draws. VALUES: empty = the currently selected gun;
|
||||
# `all` or `*` = every gun; otherwise a gun name, e.g. `Pattern`.
|
||||
# TRY: TR_VBULLET_DEBUG_GUN=all -> every admitted gun at once.
|
||||
TR_VBULLET_DEBUG_GUN=
|
||||
TR_VBULLET_DEBUG_MAX=32 # max virtual bullets drawn per gun (clamped to >= 1)
|
||||
TR_VBULLET_ADMIT_ONLY=on # on = a gun the rack does not admit is not even predicted
|
||||
|
||||
# ── logs (set the value to 1; presence alone turns some of them on) ──────────
|
||||
TR_RESULT_LOG=on # one line per round result
|
||||
#TR_POWER_LOG=1 # presence-only: one line per power decision
|
||||
#TR_RAM_LOG=1 # presence-only: one line per ram start/stop and why
|
||||
#TR_MOVEMENT_LOG=1 # presence-only: movement band/class changes
|
||||
#TR_STRAFE_LOG=1 # presence-only: one line per strafe tile pick
|
||||
#TR_TMHORIZON_LOG=1 # presence-only: the per-shot thinking of the TM horizon gun
|
||||
# ── logs (set the value to 1; PRESENCE alone turns these on) ─────────────────
|
||||
# WHAT: one line per round result. VALUE-based (unlike the block below): 0/off
|
||||
# really disables it, which is why this one is written out uncommented.
|
||||
# TRY: TR_RESULT_LOG=off -> no [result] lines at all.
|
||||
TR_RESULT_LOG=on
|
||||
#TR_POWER_LOG=1 # PRESENCE-only: one line per power decision (0 would ENABLE it)
|
||||
#TR_RAM_LOG=1 # PRESENCE-only: one line per ram start/stop and why
|
||||
#TR_MOVEMENT_LOG=1 # PRESENCE-only: movement band/class changes
|
||||
#TR_STRAFE_LOG=1 # PRESENCE-only: one line per strafe tile pick
|
||||
#TR_TMHORIZON_LOG=1 # VALUE-based: the per-shot thinking of the TM horizon gun
|
||||
GUN_STATS_PATH=/tmp/gun_stats.jsonl # where the per-round gun stats are written
|
||||
GUN_SHOTLOG_PATH=/tmp/shot_log.jsonl # where the per-shot log is written
|
||||
|
||||
# ── measurement helpers (leave off unless you are measuring) ─────────────────
|
||||
#TR_RECORD_WORLDSTATE=1 # presence-only: dump every observed world state
|
||||
#TR_RADAR_SCANLOG=1 # presence-only: log every radar scan tick
|
||||
#TR_TRACKER_PROBE=1 # presence-only: dump the enemy-tracker's internal state
|
||||
# WHAT: aim capture. Adds TWO record kinds to the TR_RECORD_WORLDSTATE file:
|
||||
# aim_scan — one per radar scan: the raw reading, our own state, the gun
|
||||
# that fired, the PREVIOUS tracker belief, and the scan parity
|
||||
# (age = tick - previous lastSeenTick);
|
||||
# aim_fire — one per real shot: gun, power, the aim angle, the turret error,
|
||||
# gun heat, the predicted intercept/TOF, and the exact WorldState the
|
||||
# predictor consumed (with the tick it came from). It also adds the `gun`
|
||||
# id to the per-tick world-state rows.
|
||||
# VALUES: presence-only, like the other keys in this block. Unset = off.
|
||||
# STATUS: default-off, diagnostic only, never live-tested. j176 could not
|
||||
# attribute the 11.9 deg aim error because the corpus had no gun id and no
|
||||
# bot-side belief; this knob makes both a lookup instead of an inverse
|
||||
# problem. No aim model changed with it.
|
||||
# GOTCHA: it only writes when TR_RECORD_WORLDSTATE is on as well, and it makes
|
||||
# the capture file bigger, not different: the extra lines are annotations and
|
||||
# the offline replay skips them.
|
||||
# TRY: TR_RECORD_WORLDSTATE=1 TR_CAPTURE_AIM=1 ./out/ModularBot
|
||||
#TR_CAPTURE_AIM=1 # PRESENCE-only: aim_scan / aim_fire records (needs TR_RECORD_WORLDSTATE)
|
||||
#TR_RECORD_WORLDSTATE=1 # PRESENCE-only: dump every observed world state
|
||||
#TR_RADAR_SCANLOG=1 # PRESENCE-only: log every radar scan tick
|
||||
#TR_TRACKER_PROBE=1 # PRESENCE-only: dump the enemy-tracker's internal state
|
||||
TR_TRACKER_PROBE_PATH=/tmp/tracker_probe.jsonl # where that dump is written
|
||||
|
||||
# ── dotenv / boot report ─────────────────────────────────────────────────────
|
||||
# Name of the env file to load. Must be set in the REAL environment, not in the
|
||||
# file it points at. Empty = use ./.env, else .env next to the binary.
|
||||
# GOTCHA: the line below sets it to the EMPTY string, which is the correct
|
||||
# "use the default" spelling; putting a real path here would make this file
|
||||
# load itself, recursively, at every start.
|
||||
TR_ENV_FILE=
|
||||
# 1 = print the [env] report on startup (default). 0 = do not print it.
|
||||
TR_ENV_REPORT=1
|
||||
|
||||
@@ -44,6 +44,7 @@ import movement_harness/bullet_shadows
|
||||
import targeting/enemy_tracker
|
||||
import targeting/target_selector
|
||||
import env_report
|
||||
import aim_capture
|
||||
import vbullet_draw
|
||||
import geo_overlay
|
||||
|
||||
@@ -69,6 +70,13 @@ const ShotLog = true
|
||||
## can enable recording for just the battle it spawns by exporting the env var.
|
||||
let RecordWorldState* = existsEnv("TR_RECORD_WORLDSTATE")
|
||||
const WorldStateRecordPath = "/tmp/worldstate_record.jsonl"
|
||||
## j177 aim capture: with TR_CAPTURE_AIM set, append two extra record kinds to
|
||||
## the SAME world-state file — `aim_scan` (one per radar scan: the raw reading,
|
||||
## our own state, the GUN, the previous belief and the scan parity) and
|
||||
## `aim_fire` (one per firing decision: the gun, the power, the aim angle, the
|
||||
## turret error, and the exact WorldState the predictor consumed). Off by
|
||||
## default, so a normal run writes byte-for-byte what it wrote before.
|
||||
let CaptureAim* = existsEnv("TR_CAPTURE_AIM")
|
||||
## Radar measurement switches (all RUNTIME, read once at process start):
|
||||
## TR_RADAR_FORCE_SPIN=1 force the melee radar to the old stateless full
|
||||
## spin (always 45 deg/tick). This reproduces the
|
||||
@@ -477,6 +485,9 @@ proc recordWorldState(bot: ModularBot, ws: WorldState) =
|
||||
"eid": tid,
|
||||
}
|
||||
if lst >= 0: row["lst"] = %lst
|
||||
# j177: the gun in force when this state was built. Absent before j177,
|
||||
# which made a per-gun decomposition of the aim error impossible.
|
||||
if CaptureAim: row["gun"] = %bot.currentGun
|
||||
try:
|
||||
let f = open(WorldStateRecordPath, fmAppend)
|
||||
f.writeLine($row)
|
||||
@@ -616,6 +627,23 @@ proc recordRadarStats(bot: ModularBot) =
|
||||
inc bot.arcWidthHist[min(11, int(width / 30.0))]
|
||||
|
||||
method onScannedBot*(bot: ModularBot, e: ScannedBotEvent) =
|
||||
# j177 aim capture: the belief we are about to REPLACE, and the fire site's
|
||||
# state, recorded BEFORE the update. Written first so the record is the
|
||||
# pre-update belief by construction, not by argument.
|
||||
if CaptureAim:
|
||||
var rec = AimScan(tick: bot.tick, eid: e.scannedBotId,
|
||||
ex: e.x, ey: e.y, eh: e.direction, es: e.speed, ee: e.energy,
|
||||
sx: getX(), sy: getY(), sh: getDirection(), ss: getSpeed(),
|
||||
gun: bot.currentGun,
|
||||
rlock: bot.radarMode == 0, rdir: getRadarDirection(),
|
||||
bx: 0.0, by: 0.0, blst: -1)
|
||||
if bot.enemyTracker.enemies.contains(e.scannedBotId):
|
||||
let prev = bot.enemyTracker.enemies[e.scannedBotId]
|
||||
rec.bx = prev.x; rec.by = prev.y; rec.bh = prev.heading
|
||||
rec.bs = prev.speed; rec.blst = prev.lastSeenTick
|
||||
rec.lbear = bearing(rec.bx, rec.by, rec.sx, rec.sy)
|
||||
rec.boff = (bearing(rec.ex, rec.ey, rec.sx, rec.sy) - rec.rdir) mod 360.0
|
||||
appendLine(WorldStateRecordPath, scanRow(rec))
|
||||
bot.enemyTracker.update(e.scannedBotId, e.x, e.y, e.direction, e.speed, e.energy, bot.tick)
|
||||
bot.hasContact = true
|
||||
if RadarScanLog and bot.radarMeleeActive:
|
||||
@@ -878,6 +906,11 @@ method onRoundEnded*(bot: ModularBot, e: RoundEndedEventForBot) =
|
||||
|
||||
method onRoundStarted*(bot: ModularBot, e: RoundStartedEvent) =
|
||||
bot.roundNumber = e.roundNumber
|
||||
if FireDiag:
|
||||
# j147 timeline anchor: the (bot.tick -> server getTurn) offset, once per
|
||||
# round, so a recorded capture's event sidecar can be aligned to the bot's
|
||||
# own tick stream without guessing.
|
||||
echo "[firediag] ROUND round=", getRound(), " getTurn=", getTurn()
|
||||
# Per-round outcome-log state (TR_RESULT_LOG). Reset every round so round 1
|
||||
# reports and a target/enemy change mid-round cannot leak a stale flag.
|
||||
bot.weDiedThisRound = false
|
||||
@@ -1454,9 +1487,30 @@ method run*(bot: ModularBot) =
|
||||
let distPx = hypot(pred.x - getX(), pred.y - getY())
|
||||
|
||||
if shouldFire(gunDir, aimTarget, gunHeat, distPx):
|
||||
# j160 FIRING FLOOR: at/below TR_RAM_FLOOR_ENERGY self energy we hold
|
||||
# the reserve for the ram instead of spending it on a shot. Off by
|
||||
# default (`RamFloorEnergy = 0.0`), and `ramming` (the exhaustion
|
||||
# trigger) wins the conflict, so an engaged ram never starves itself.
|
||||
let floorBlocks = fireFloorBlocks(RamFloorEnergy, getEnergy(), shouldRam)
|
||||
# Enqueue the selected gun so onBulletFired can stamp the server's bulletId.
|
||||
# getEnergy() > power mirrors the server's "bot.energy <= firepower" reject.
|
||||
if setFire(power) and getEnergy() > power:
|
||||
if not floorBlocks and setFire(power) and getEnergy() > power:
|
||||
# j177 aim capture: one `aim_fire` line per REAL shot, with the gun
|
||||
# that fired and the exact WorldState the predictor consumed. This
|
||||
# is the only place where `lst` is knowable, so it is the only place
|
||||
# the scan parity of a firing decision can be recorded.
|
||||
if CaptureAim:
|
||||
let bspd = bulletSpeed(power)
|
||||
appendLine(WorldStateRecordPath, fireRow(AimFire(
|
||||
tick: bot.tick, eid: tid, gun: selectedGun, power: power,
|
||||
aim: aimTarget, turret: gunDir, terr: normDelta, heat: gunHeat,
|
||||
ax: pred.x, ay: pred.y, tof: (if bspd > 0: distPx / bspd else: 0.0),
|
||||
ex: bot.lastState.enemyX, ey: bot.lastState.enemyY,
|
||||
eh: bot.lastState.enemyHeading, es: bot.lastState.enemySpeed,
|
||||
ee: bot.lastState.enemyEnergy,
|
||||
sx: bot.lastState.selfX, sy: bot.lastState.selfY,
|
||||
lst: (if tid >= 0 and bot.enemyTracker.enemies.contains(tid):
|
||||
bot.enemyTracker.enemies[tid].lastSeenTick else: -1))))
|
||||
bot.pendingFires.add(PendingShot(
|
||||
gunId: selectedGun,
|
||||
angleErr: abs(normDelta),
|
||||
@@ -1548,6 +1602,7 @@ when isMainModule:
|
||||
vBulletDebugGun: getEnv(VBulletDebugGunEnv, ""),
|
||||
vBulletDebugMax: VBulletDebugMax,
|
||||
recordWorldState: RecordWorldState,
|
||||
captureAim: CaptureAim,
|
||||
geoDebug: GeoDebugOn,
|
||||
debugDraw: DebugDrawOn,
|
||||
radarForceSpin: RadarForceSpin,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
## j177 aim capture — the two records that make the aim error ATTRIBUTABLE.
|
||||
##
|
||||
## j176 could not answer "why is the aim 11.9 deg off at 450+ px" because the
|
||||
## corpus has neither the bot's own belief (staleness was an INVERSE problem,
|
||||
## unidentifiable) nor the gun id (a good gun's contribution was
|
||||
## indistinguishable from a bad one's). Both are cheap to log and impossible
|
||||
## to recover later. This module builds the two JSON records; ModularBot.nim
|
||||
## calls it and the lines go into the EXISTING world-state capture
|
||||
## (`TR_RECORD_WORLDSTATE` file), so the offline tooling sees one stream.
|
||||
##
|
||||
## It is deliberately PURE (no bot API, no env reads): the bot passes plain
|
||||
## floats, the offline guard test passes the recorded fixture, and both go
|
||||
## through the SAME row builders — so a field that the test proves present is
|
||||
## a field the live bot writes.
|
||||
##
|
||||
## Key convention: `e*` = the enemy, `s*` = us, `b*` = the enemy's PREVIOUS
|
||||
## belief in the tracker (before this scan's update), `lst` = the tick that
|
||||
## state came from. `age = tick - blst` is the scan PARITY, recorded rather
|
||||
## than inferred.
|
||||
|
||||
import std/[json, os, math]
|
||||
|
||||
const
|
||||
ScanRecordKey* = "aim_scan" ## wrapper key, sibling of "meta"/"end"
|
||||
FireRecordKey* = "aim_fire"
|
||||
|
||||
type
|
||||
AimScan* = object
|
||||
## One `onScannedBot` event, as the bot saw it BEFORE the update.
|
||||
tick*: int
|
||||
eid*: int
|
||||
ex*, ey*: float ## raw scanned values
|
||||
eh*, es*: float ## scanned heading (= direction) and speed
|
||||
ee*: float
|
||||
sx*, sy*: float ## our state at the scan (the FIRE SITE)
|
||||
sh*, ss*: float
|
||||
gun*: int ## bot.currentGun — the gun that fired, not the rack slot
|
||||
bx*, by*: float ## previous belief, BEFORE this scan's update
|
||||
bh*, bs*: float
|
||||
blst*: int ## previous lastSeenTick; -1 = never scanned before
|
||||
rlock*: bool ## radar lock engaged (false = the melee radar)
|
||||
rdir*: float ## 36 deg scan window centre (radar heading)
|
||||
lbear*: float ## bearing the lock is chasing (believed target)
|
||||
boff*: float ## scanned bearing - rdir: where in the window it landed
|
||||
|
||||
AimFire* = object
|
||||
## One firing decision, as the model computed it.
|
||||
tick*: int
|
||||
eid*: int
|
||||
gun*: int ## bot.currentGun = the gun that fired
|
||||
power*: float
|
||||
aim*: float ## the raw angle handed to setFire/turret
|
||||
turret*: float ## getGunDirection() at the command
|
||||
terr*: float ## signed turret error (aim - turret)
|
||||
heat*: float ## getGunHeat() BEFORE firing
|
||||
ax*, ay*: float ## the intercept the gun predicted
|
||||
tof*: float ## implied time of flight, ticks
|
||||
ex*, ey*: float ## the WorldState the predictor CONSUMED
|
||||
eh*, es*: float
|
||||
ee*: float
|
||||
sx*, sy*: float
|
||||
lst*: int ## which tick that enemy state came from (scan parity)
|
||||
|
||||
proc bearing*(x, y, fx, fy: float): float =
|
||||
arctan2(y - fy, x - fx).radToDeg
|
||||
|
||||
proc scanRow*(s: AimScan): JsonNode =
|
||||
## The `aim_scan` line. Every field is unconditional: a capture that
|
||||
## silently omits a field is worse than no capture.
|
||||
result = newJObject()
|
||||
result[ScanRecordKey] = newJObject()
|
||||
let b = result[ScanRecordKey]
|
||||
b["tick"] = %s.tick
|
||||
b["eid"] = %s.eid
|
||||
b["ex"] = %s.ex
|
||||
b["ey"] = %s.ey
|
||||
b["eh"] = %s.eh
|
||||
b["es"] = %s.es
|
||||
b["ee"] = %s.ee
|
||||
b["sx"] = %s.sx
|
||||
b["sy"] = %s.sy
|
||||
b["sh"] = %s.sh
|
||||
b["ss"] = %s.ss
|
||||
b["gun"] = %s.gun
|
||||
b["bx"] = %s.bx
|
||||
b["by"] = %s.by
|
||||
b["bh"] = %s.bh
|
||||
b["bs"] = %s.bs
|
||||
b["blst"] = %s.blst
|
||||
b["age"] = %(if s.blst >= 0: s.tick - s.blst else: -1)
|
||||
b["rlock"] = %s.rlock
|
||||
b["rdir"] = %s.rdir
|
||||
b["lbear"] = %s.lbear
|
||||
b["boff"] = %s.boff
|
||||
|
||||
proc fireRow*(f: AimFire): JsonNode =
|
||||
## The `aim_fire` line.
|
||||
result = newJObject()
|
||||
result[FireRecordKey] = newJObject()
|
||||
let b = result[FireRecordKey]
|
||||
b["tick"] = %f.tick
|
||||
b["eid"] = %f.eid
|
||||
b["gun"] = %f.gun
|
||||
b["power"] = %f.power
|
||||
b["aim"] = %f.aim
|
||||
b["turret"] = %f.turret
|
||||
b["terr"] = %f.terr
|
||||
b["heat"] = %f.heat
|
||||
b["ax"] = %f.ax
|
||||
b["ay"] = %f.ay
|
||||
b["tof"] = %f.tof
|
||||
b["ex"] = %f.ex
|
||||
b["ey"] = %f.ey
|
||||
b["eh"] = %f.eh
|
||||
b["es"] = %f.es
|
||||
b["ee"] = %f.ee
|
||||
b["sx"] = %f.sx
|
||||
b["sy"] = %f.sy
|
||||
b["lst"] = %f.lst
|
||||
|
||||
proc appendLine*(path: string, row: JsonNode) =
|
||||
## Append one JSONL line, fully guarded: a full disk or a bad path must
|
||||
## never take the bot down (same contract as the existing recorders).
|
||||
try:
|
||||
let f = open(path, fmAppend)
|
||||
f.writeLine($row)
|
||||
f.close()
|
||||
except CatchableError:
|
||||
discard
|
||||
@@ -25,6 +25,7 @@ import module_switches
|
||||
import gun_harness/virtual_bullets
|
||||
import gun_harness/selector
|
||||
import movements/ram_decision
|
||||
import movement_harness/fire_tracker
|
||||
import movements/the_floor_is_lava
|
||||
import movements/the_floor_is_lava_ring
|
||||
import movements/strafe
|
||||
@@ -51,6 +52,7 @@ type
|
||||
vBulletDebugGun*: string
|
||||
vBulletDebugMax*: int
|
||||
recordWorldState*: bool
|
||||
captureAim*: bool ## j177: aim_scan / aim_fire records, default off
|
||||
geoDebug*: bool
|
||||
debugDraw*: bool
|
||||
radarForceSpin*: bool
|
||||
@@ -244,6 +246,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_RESULT_LOG", onOff(ctx.resultLog), sourceOf("TR_RESULT_LOG"))
|
||||
emit("TR_RECORD_WORLDSTATE", onOff(ctx.recordWorldState),
|
||||
sourceOfPresence("TR_RECORD_WORLDSTATE"))
|
||||
emit("TR_CAPTURE_AIM", onOff(ctx.captureAim),
|
||||
sourceOfPresence("TR_CAPTURE_AIM"))
|
||||
emit("TR_RADAR_FORCE_SPIN", onOff(ctx.radarForceSpin),
|
||||
sourceOfPresence("TR_RADAR_FORCE_SPIN"))
|
||||
emit("TR_RADAR_SCANLOG", onOff(ctx.radarScanLog),
|
||||
@@ -308,8 +312,22 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_TFIL_RANGE_HI", $RangeHi, sourceOf("TR_TFIL_RANGE_HI"))
|
||||
emit("TR_TFIL_RANGE_TEMP", $RangeTemp, sourceOf("TR_TFIL_RANGE_TEMP"))
|
||||
emit("TR_TFIL_RANGE_K", $RangeK, sourceOf("TR_TFIL_RANGE_K"))
|
||||
# j165: the ring fork's own arrival commitment (default off). RING-SPECIFIC
|
||||
# names, so they can never be confused with the tfil mover's TR_TFIL_* pair.
|
||||
emit("TR_TFIL_RING_COMMIT_ARRIVAL", onOff(TfilRingCommitArrival),
|
||||
sourceOf("TR_TFIL_RING_COMMIT_ARRIVAL"))
|
||||
emit("TR_TFIL_RING_NOREV_SPEED", $TfilRingNoRevSpeed,
|
||||
sourceOf("TR_TFIL_RING_NOREV_SPEED"))
|
||||
emit("TR_TFIL_CORRIDOR_HEAT", $the_floor_is_lava_ring.CorridorHeat,
|
||||
sourceOf("TR_TFIL_CORRIDOR_HEAT"))
|
||||
emit("TR_TFIL_CORRIDOR_TICKS", $the_floor_is_lava.TfilCorridorTicks,
|
||||
sourceOf("TR_TFIL_CORRIDOR_TICKS"))
|
||||
emit("TR_TFIL_ARRIVE_TICKS", $the_floor_is_lava.TfilArriveTicks,
|
||||
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,
|
||||
@@ -328,6 +346,14 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_TFIL_COMMIT_MARGIN", $TfilCommitMargin,
|
||||
sourceOf("TR_TFIL_COMMIT_MARGIN"))
|
||||
emit("TR_TFIL_NOREV_SPEED", $TfilNoRevSpeed, sourceOf("TR_TFIL_NOREV_SPEED"))
|
||||
emit("TR_TFIL_DANGER_THRESHOLD", $the_floor_is_lava.TfilDangerThreshold,
|
||||
sourceOf("TR_TFIL_DANGER_THRESHOLD"))
|
||||
emit("TR_TFIL_DIAG", onOff(the_floor_is_lava.TfilDiag),
|
||||
sourceOfPresence("TR_TFIL_DIAG"))
|
||||
emit("TR_TFIL_GEO_MODE", $the_floor_is_lava.TfilGeoMode,
|
||||
sourceOf("TR_TFIL_GEO_MODE"))
|
||||
emit("TR_TFIL_GEO_TAU", $the_floor_is_lava.TfilGeoTau,
|
||||
sourceOf("TR_TFIL_GEO_TAU"))
|
||||
emit("TR_TFIL_TURN_BIAS", $TfilTurnBias, sourceOf("TR_TFIL_TURN_BIAS"))
|
||||
emit("TR_TFIL_TURN_REF_DEG", $TfilTurnRefDeg, sourceOf("TR_TFIL_TURN_REF_DEG"))
|
||||
# time-indexed bullet heat (default off = shipped flat model)
|
||||
@@ -362,6 +388,9 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_FIRE_FIX", onOff(TfilFireFix), sourceOf("TR_FIRE_FIX"))
|
||||
# j134 TASK B diagnostic (off by default): per-reading tick/raw/correction trace.
|
||||
emit("TR_FIRE_DIAG", onOff(StrafeFireDiag), sourceOf("TR_FIRE_DIAG"))
|
||||
# j147: the back-date (ticks) applied to every detected enemy fire at spawn.
|
||||
# 0 = shipped (the ghost is born at the scanned enemy position).
|
||||
emit("TR_FIRE_LAG", $FireLag, sourceOf("TR_FIRE_LAG"))
|
||||
emit("TR_STRAFE_HEAT_GRID", onOff(StrafeHeatGrid), sourceOf("TR_STRAFE_HEAT_GRID"))
|
||||
# STRAFE's heat shape is its own RETUNE (bullet 20/10, corridor 10, wall
|
||||
# 15/5), override-able per run so the shipped field can be A/B'd on one
|
||||
@@ -369,6 +398,7 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_STRAFE_BULLET_CORE", $StrafeBulletCore, sourceOf("TR_STRAFE_BULLET_CORE"))
|
||||
emit("TR_STRAFE_BULLET_AURA", $StrafeBulletAura, sourceOf("TR_STRAFE_BULLET_AURA"))
|
||||
emit("TR_STRAFE_CORRIDOR_HEAT", $StrafeCorridorHeat, sourceOf("TR_STRAFE_CORRIDOR_HEAT"))
|
||||
emit("TR_STRAFE_CORRIDOR_TICKS", $StrafeCorridorTicks, sourceOf("TR_STRAFE_CORRIDOR_TICKS"))
|
||||
emit("TR_STRAFE_WALL_HOTNESS", $StrafeWallHotness, sourceOf("TR_STRAFE_WALL_HOTNESS"))
|
||||
emit("TR_STRAFE_WALL_RADIANCE", $StrafeWallRadiance, sourceOf("TR_STRAFE_WALL_RADIANCE"))
|
||||
|
||||
@@ -389,6 +419,8 @@ proc printEffectiveValues(ctx: EnvReportContext) =
|
||||
emit("TR_RAM_PLAN_MARGIN", $RamPlanMargin, sourceOf("TR_RAM_PLAN_MARGIN"))
|
||||
emit("TR_RAM_PLAN_HITRATE", $RamPlanHitRate, sourceOf("TR_RAM_PLAN_HITRATE"))
|
||||
emit("TR_RAM_LOG", onOff(RamLog), sourceOfPresence("TR_RAM_LOG"))
|
||||
emit("TR_RAM_FLOOR_ENERGY", $RamFloorEnergy, sourceOf("TR_RAM_FLOOR_ENERGY"))
|
||||
emit("TR_RAM_ENEMY_ENERGY", $RamEnemyEnergy, sourceOf("TR_RAM_ENEMY_ENERGY"))
|
||||
|
||||
# ── the horizon TM gun ────────────────────────────────────────────────────
|
||||
# `resetLearning`/`targetChanged` resolve the lazily-read fields at round
|
||||
@@ -628,7 +660,7 @@ proc knownEnvNames*(): seq[string] =
|
||||
"GUN_SELECTOR_SHRINK", "GUN_SELECTOR_DWELL", "GUN_SELECTOR_MARGIN",
|
||||
"GUN_SELECTOR_POINT_TIE", "GUN_SELECTOR_SEED",
|
||||
"GUN_RACK_DISABLE", "GUN_STATS_PATH", "GUN_SHOTLOG_PATH",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE",
|
||||
"TR_MOVEMENT", "TR_MOVEMENT_LOG", "TR_RECORD_WORLDSTATE", "TR_CAPTURE_AIM",
|
||||
"TR_RADAR_FORCE_SPIN", "TR_RADAR_SCANLOG", "TR_RADAR_SCAN_LOG_PATH",
|
||||
"TR_TRACKER_PROBE", "TR_TRACKER_PROBE_PATH", "TR_VBULLET_ADMIT_ONLY",
|
||||
VBulletDebugEnv, VBulletDebugGunEnv, VBulletDebugMaxEnv,
|
||||
@@ -640,8 +672,14 @@ proc knownEnvNames*(): seq[string] =
|
||||
"TR_RAM_OPPORTUNITY", "TR_RAM_OPP_DIST", "TR_RAM_OPP_MARGIN",
|
||||
"TR_RAM_ABORT_DMG", "TR_RAM_PLAN", "TR_RAM_PLAN_DIST",
|
||||
"TR_RAM_PLAN_MARGIN", "TR_RAM_PLAN_HITRATE", "TR_RAM_LOG",
|
||||
"TR_RAM_FLOOR_ENERGY", "TR_RAM_ENEMY_ENERGY",
|
||||
"TR_TFIL_RANGE_LO", "TR_TFIL_RANGE_HI", "TR_TFIL_RANGE_TEMP",
|
||||
"TR_TFIL_RING_COMMIT_ARRIVAL", "TR_TFIL_RING_NOREV_SPEED",
|
||||
"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_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",
|
||||
"TR_TFIL_TILE_REPLAN", "TR_TFIL_COMMIT_TICKS", "TR_TFIL_NO_REV",
|
||||
"TR_TFIL_COMMIT_LOG", "TR_TFIL_COMMIT_ARRIVAL", "TR_TFIL_COMMIT_MARGIN",
|
||||
@@ -657,8 +695,10 @@ proc knownEnvNames*(): seq[string] =
|
||||
"TR_STRAFE_FIRE_FIX",
|
||||
"TR_FIRE_FIX",
|
||||
"TR_FIRE_DIAG",
|
||||
"TR_FIRE_LAG",
|
||||
"TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
|
||||
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
|
||||
"TR_STRAFE_CORRIDOR_TICKS",
|
||||
"TR_STRAFE_WALL_RADIANCE",
|
||||
SurfPrefDistEnv, SurfDistBandEnv, SurfWallMarginEnv, SurfRadialFracEnv,
|
||||
SurfLogEnv,
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
## A trailing live end marker is also optional:
|
||||
## {"end":{"enemy_died":<bool>,"ticks":<int>}}
|
||||
## It lets the replay reproduce the live resolver's final-tick behaviour.
|
||||
## `aim_scan` / `aim_fire` annotation lines (written only when
|
||||
## TR_CAPTURE_AIM is set) carry no `ex` and are skipped.
|
||||
##
|
||||
## The replay never calls the gun selector, so it is RNG-free for every
|
||||
## deterministic gun. Tsetlin is stochastic and is expected to differ.
|
||||
@@ -196,6 +198,10 @@ proc loadFixture*(path: string): Fixture =
|
||||
if node["end"].hasKey("enemy_died"):
|
||||
result.enemyDied = node["end"]["enemy_died"].getBool()
|
||||
continue
|
||||
# j177: the recorder can also write `aim_scan` / `aim_fire` lines into the
|
||||
# same file when TR_CAPTURE_AIM is set. They are annotations on ticks, not
|
||||
# ticks, so they carry no `ex` and are skipped here.
|
||||
if not node.hasKey("ex"): continue
|
||||
result.states.add stateFromJson(node, arenaW, arenaH, enemyId)
|
||||
result.lastSeen.add (if node.hasKey("lst"): node["lst"].getInt() else: -1)
|
||||
result.enemyId = enemyId
|
||||
|
||||
@@ -134,6 +134,38 @@ proc detect*(t: var FireTracker, id: int, energy: float,
|
||||
elif drop >= lo and drop <= hi:
|
||||
result = @[drop]
|
||||
|
||||
import std/[math, os, strutils]
|
||||
|
||||
## ── j147: the DETECTION LAG back-date (`TR_FIRE_LAG`, default 0) ─────────────
|
||||
## MEASURED LIVE (`common_libs/tests/measure_fire_ghost_lag.py`, 4 sessions,
|
||||
## 1777 matched ghost spawns over both movers, `TR_FIRE_DIAG=1`): the server
|
||||
## dispatches a turn's fire AFTER our `go()` for that same turn, so the energy
|
||||
## drop of a turn-T shot first reaches our scan at turn T+1 (our bot tick T). A bullet
|
||||
## takes its FIRST step during the turn it is fired, so by then the true bullet
|
||||
## is already `speed` px (11..20 px, one whole bullet step) downrange and the
|
||||
## arrival deadline is a full tick shorter than the ghost's. Both movers place
|
||||
## the ghost at the SCANNED enemy position, i.e. exactly where the bullet was
|
||||
## born: the whole ghost trajectory is the true one shifted one turn later.
|
||||
## The per-tick `advanceBullets` then keeps it there for the bullet's whole life.
|
||||
##
|
||||
## The compensation is the inverse: at SPAWN, back-date the shot by `lag` ticks
|
||||
## (`x = origin + dir * speed * lag`, `y = ...`). The arrival deadline needs no
|
||||
## separate change — every mover derives it from the ghost's own position
|
||||
## (`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
|
||||
## correct deadline.
|
||||
##
|
||||
## DEFAULT 0 = the shipped behaviour, byte for byte (`x` is only touched when
|
||||
## `lag > 0`), so the default-parity guard stays green.
|
||||
var FireLag*: int = 0
|
||||
|
||||
proc loadFireTrackerEnv*() =
|
||||
## Read the shared fire knobs. Called once at module init; callable again
|
||||
## after `putEnv` so a guard test can exercise the arms in one process.
|
||||
let s = getEnv("TR_FIRE_LAG", "").strip()
|
||||
FireLag = (try: max(0, parseInt(s)) except ValueError: 0)
|
||||
|
||||
loadFireTrackerEnv()
|
||||
|
||||
proc endScan*(t: var FireTracker) =
|
||||
## Call once after the per-enemy scan. Rotates the event corrections one
|
||||
## slot: the events noted since the previous `endScan` become the corrections
|
||||
|
||||
@@ -46,7 +46,29 @@
|
||||
## TR_RAM_PLAN_MARGIN default 20.0 change-of-plan energy advantage
|
||||
## TR_RAM_PLAN_HITRATE default 0.05 selected gun's pooled virtual hit rate
|
||||
## below which the gun duel counts as failing
|
||||
## TR_RAM_LOG=1 emit one change-gated `[ram]` line
|
||||
## ## TR_RAM_LOG=1 emit one change-gated `[ram]` line
|
||||
## TR_RAM_FLOOR_ENERGY default 0.0 FIRING FLOOR (j160). At or below this
|
||||
## self energy we stop firing to keep a
|
||||
## ram reserve. 0 = off = today's behaviour.
|
||||
## TR_RAM_ENEMY_ENERGY default 0.0 ENEMY-EXHAUSTION trigger (j160). The
|
||||
## last-scanned enemy energy <= this ->
|
||||
## ram mode. 0 = off.
|
||||
##
|
||||
## ── j160: the energy-reserve + exhaustion policy ───────────────────────────
|
||||
## Energy NEVER regenerates and has no cap; the only gain in the whole game is
|
||||
## `+3 * power` per bullet hit LANDED (server `rules.kt`). So not firing denies
|
||||
## the enemy its only refill AND keeps our ram reserve intact — the two halves
|
||||
## of the policy are the same bet.
|
||||
##
|
||||
## Floor sizing: one likely return hit (`bulletDamage(1.0)` = 4.0) plus two
|
||||
## 0.1-power shots (0.1 each) is 4.2. The knob DEFAULT stays 0.0 so the default
|
||||
## path is byte-identical; the operator sets 5-ish.
|
||||
##
|
||||
## The exhaustion trigger is the FINISHER with the energy tolerance promoted to
|
||||
## an operator knob. It deliberately KEEPS the finisher's own
|
||||
## `selfEnergy > enemyEnergy` surplus guard: `RAM_DAMAGE 0.6` is applied to BOTH
|
||||
## bots on every contact tick, so a head-on contact is a symmetric bleed decided
|
||||
## by who walks in with the surplus.
|
||||
|
||||
import std/[os, strutils]
|
||||
|
||||
@@ -95,10 +117,15 @@ let RamPlanDist* = getEnvFloat("TR_RAM_PLAN_DIST", DefaultRamPlanDist)
|
||||
let RamPlanMargin* = getEnvFloat("TR_RAM_PLAN_MARGIN", DefaultRamPlanMargin)
|
||||
let RamPlanHitRate* = getEnvFloat("TR_RAM_PLAN_HITRATE", DefaultRamPlanHitRate)
|
||||
let RamLog* = existsEnv("TR_RAM_LOG")
|
||||
## j160. 0.0 = off on BOTH knobs, which is the shipped behaviour.
|
||||
let RamFloorEnergy* = getEnvFloat("TR_RAM_FLOOR_ENERGY", 0.0)
|
||||
let RamEnemyEnergy* = getEnvFloat("TR_RAM_ENEMY_ENERGY", 0.0)
|
||||
|
||||
type
|
||||
RamReason* = enum
|
||||
rrNone ## no trigger fires
|
||||
rrExhausted ## j160: last-scanned enemy energy <= TR_RAM_ENEMY_ENERGY
|
||||
## and we hold the surplus (it is out of ammo, we are not)
|
||||
rrFinisher ## enemy < 20 energy, we are healthier, dist < 300
|
||||
rrOpportunity ## we clearly out-energise and are close enough to close
|
||||
rrDesperation ## both nearly dead, short range
|
||||
@@ -131,7 +158,8 @@ proc ramTrigger*(inp: RamInputs,
|
||||
planEnabled = RamPlanEnabled,
|
||||
planDist = RamPlanDist,
|
||||
planMargin = RamPlanMargin,
|
||||
planHitRate = RamPlanHitRate): RamReason =
|
||||
planHitRate = RamPlanHitRate,
|
||||
enemyEnergyTol = RamEnemyEnergy): RamReason =
|
||||
## Pure trigger evaluation. Returns the FIRST matching reason in priority
|
||||
## order, or `rrNone`. Cooldown/duration/abort are deliberately NOT here — the
|
||||
## caller composes those, so this function has no state and is unit-testable.
|
||||
@@ -143,6 +171,13 @@ proc ramTrigger*(inp: RamInputs,
|
||||
## `desperation` and `finisher` are kept: they are rare, short-range, and the
|
||||
## finisher is the only measured conversion. `plan` remains opt-in and off.
|
||||
if inp.enemyEnergy <= 0.0: return rrNone
|
||||
# j160 exhaustion trigger. Checked FIRST so the operator-set tolerance wins
|
||||
# the label when it is set; it is the finisher's own shape (same surplus and
|
||||
# distance guards) with the 20.0 energy tolerance promoted to a knob. With
|
||||
# `enemyEnergyTol = 0.0` (the default) this arm can never fire.
|
||||
if enemyEnergyTol > 0.0 and inp.enemyEnergy <= enemyEnergyTol and
|
||||
inp.dist < RamFinisherDist and inp.selfEnergy > inp.enemyEnergy:
|
||||
return rrExhausted
|
||||
if inp.dist < RamFinisherDist and inp.enemyEnergy < RamFinisherEnergy and
|
||||
inp.selfEnergy > inp.enemyEnergy:
|
||||
return rrFinisher
|
||||
@@ -158,9 +193,24 @@ proc ramTrigger*(inp: RamInputs,
|
||||
return rrPlan
|
||||
rrNone
|
||||
|
||||
proc fireFloorBlocks*(floor, selfEnergy: float, ramming = false): bool =
|
||||
## j160 FIRING FLOOR. True when the reserve is thin enough that we must not
|
||||
## commit a NEW shot. `floor = 0.0` (the default) disables the floor entirely
|
||||
## and returns false for every input, so the default path is unchanged.
|
||||
##
|
||||
## `ramming` WINS over the floor: once ram mode is engaged the duel is over,
|
||||
## so the reserve is being spent on the contact, not held for it. This is the
|
||||
## same exemption `ramming` already gets in `applyPowerPolicy`.
|
||||
##
|
||||
## The floor blocks only NEW shots. A bullet already in the air (gun heat > 0)
|
||||
## is untouched — `shouldFire` already gates on `gunHeat <= 0.0`, so there is
|
||||
## no committed shot for the floor to suppress or cancel.
|
||||
not ramming and floor > 0.0 and selfEnergy <= floor
|
||||
|
||||
proc reasonName*(r: RamReason): string =
|
||||
case r
|
||||
of rrNone: "none"
|
||||
of rrExhausted: "exhausted"
|
||||
of rrFinisher: "finisher"
|
||||
of rrOpportunity: "opportunity"
|
||||
of rrDesperation: "desperation"
|
||||
|
||||
@@ -226,6 +226,13 @@ const CorridorHeatDefault = 10.0 ## corridor heat (== PathDangerThreshold)
|
||||
const WallHotnessDefault = 15.0 ## wall radiance peak (retune)
|
||||
const WallRadianceDefault = 5.0 ## wall radiance falloff (retune)
|
||||
|
||||
## j148: the corridor LENGTH bound, `TR_STRAFE_CORRIDOR_TICKS`. The shipped
|
||||
## corridor runs from the bullet to the ARENA WALL; a bullet only covers
|
||||
## `speed * t` px in `t` ticks, so a fixed TIME window is the physical length.
|
||||
## 0 (the default) = to the wall, byte-for-byte shipped.
|
||||
const DefaultStrafeCorridorTicks = 0.0
|
||||
var StrafeCorridorTicks* = DefaultStrafeCorridorTicks
|
||||
|
||||
## Heat shape is override-able so the shipped field and the retune can be
|
||||
## compared on one binary. The DEFAULTS are the retune (see the block above);
|
||||
## the knobs are strafe-specific, so the ring mover's own `TR_TFIL_*` names do
|
||||
@@ -374,6 +381,15 @@ proc loadStrafeHeatEnv*() =
|
||||
StrafeCorridorHeat = getEnvFloat("TR_STRAFE_CORRIDOR_HEAT", CorridorHeatDefault)
|
||||
StrafeWallHotness = getEnvFloat("TR_STRAFE_WALL_HOTNESS", WallHotnessDefault)
|
||||
StrafeWallRadiance = getEnvFloat("TR_STRAFE_WALL_RADIANCE", WallRadianceDefault)
|
||||
StrafeCorridorTicks = getEnvFloat("TR_STRAFE_CORRIDOR_TICKS", DefaultStrafeCorridorTicks)
|
||||
|
||||
proc strafeCorridorReach*(tWall, speed: float): float =
|
||||
## The ONE place the strafe corridor length is decided (same rule as tfil's
|
||||
## `corridorReach`, same physics: a bullet covers `speed * t` px in `t` ticks).
|
||||
## 0 (default) = to the arena wall, byte-for-byte shipped; N > 0 = N ticks.
|
||||
## Only the LENGTH changes: the heat inside the surviving corridor is today's
|
||||
## (`heatDecay(along / speed)` untouched) — this is NOT the j119 time-heat model.
|
||||
if StrafeCorridorTicks <= 0.0: tWall else: min(tWall, speed * StrafeCorridorTicks)
|
||||
|
||||
proc loadStrafeEnv*() =
|
||||
## Read the strafe knobs. Called once at module init; callable again after
|
||||
@@ -577,11 +593,19 @@ proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
|
||||
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||||
if m.bullets.len >= MaxTrackedBullets:
|
||||
m.bullets.del(0)
|
||||
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
|
||||
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
|
||||
let vx = speed * cos(heading)
|
||||
let vy = speed * sin(heading)
|
||||
var gx = ei.x
|
||||
var gy = ei.y
|
||||
if FireLag > 0:
|
||||
gx += vx * FireLag.float
|
||||
gy += vy * FireLag.float
|
||||
m.bullets.add TrackedBullet(
|
||||
originX: ei.x, originY: ei.y,
|
||||
x: ei.x, y: ei.y,
|
||||
velX: speed * cos(heading),
|
||||
velY: speed * sin(heading),
|
||||
x: gx, y: gy,
|
||||
velX: vx, velY: vy,
|
||||
power: power, alive: true, age: 0)
|
||||
|
||||
proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
|
||||
@@ -616,6 +640,16 @@ proc detectFires(m: var StrafeModule, ws: WorldState) =
|
||||
" dealt=", m.fire.dealtPending
|
||||
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, StrafeFireFix):
|
||||
m.spawnTrackedWave(ws, ei, p)
|
||||
if StrafeFireDiag and m.bullets.len > 0:
|
||||
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
|
||||
# position (the timeline anchor) and the ghost's DRAWN position.
|
||||
let b = m.bullets[^1]
|
||||
let sp = 20.0 - 3.0 * p
|
||||
echo "[firediag] SPAWN tick=", ws.tick,
|
||||
" sx=", ws.selfX, " sy=", ws.selfY,
|
||||
" gx=", b.x, " gy=", b.y,
|
||||
" p=", p,
|
||||
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
|
||||
m.fire.endScan()
|
||||
|
||||
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
|
||||
@@ -682,6 +716,7 @@ proc buildHeat(m: var StrafeModule, ws: WorldState) =
|
||||
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
|
||||
if dy > 0.0: tMin = min(tMin, (m.arenaHeight - b.y) / dy)
|
||||
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
|
||||
tMin = strafeCorridorReach(tMin, speed)
|
||||
if tMin == 0.0: continue
|
||||
let (_, auraR) = bulletRadii(b.power)
|
||||
let bMag = bulletMagScale(b.power)
|
||||
|
||||
@@ -70,6 +70,15 @@ var
|
||||
|
||||
const CommitTicks = 15 ## ticks to commit to a dodge point
|
||||
const MinCommitTicks = 5 ## must commit for this many ticks before danger replan allowed
|
||||
## j154: the DERIVED hold budget's panic horizon. Server `rules/math.kt`:
|
||||
## calcGunHeat(p) = 1 + p/5, coolDown 0.1/tick, and a gun may only fire at
|
||||
## heat == 0 (`core/GunEngine.kt:36`), so two MAX-power shots are 1.6/0.1 = 16
|
||||
## ticks apart and a 3.0-power bullet is `calcBulletDamage(3) = 16`. 16 ticks
|
||||
## is therefore the window in which the enemy can land AT MOST its next two
|
||||
## shots (32 damage) on us — the whole exposure a hold can possibly buy. It is
|
||||
## also the FIRST window that admits the enemy's second shot at all, so a
|
||||
## hold shorter than this can never be surprised by a third bullet.
|
||||
const HoldPanicTicks = 16.0
|
||||
const DangerReplanThreshold = 25.0 ## replan on serious threats only (bullet core), not corridors/auras
|
||||
const CoolestLevels = 2 ## how many distinct lava values count as "cool"
|
||||
const MaxTrackedBullets = 20 ## hard cap on tracked bullets
|
||||
@@ -85,6 +94,8 @@ const MaxTrackedBullets = 20 ## hard cap on tracked bullets
|
||||
#
|
||||
# Every default below reproduces the shipped mover byte-for-byte; see the
|
||||
# default-path parity guard in `common_libs/tests/test_tfil_commit_env.nim`.
|
||||
const DefaultDangerThreshold = 10.0 ## today's `PathDangerThreshold`
|
||||
|
||||
type
|
||||
TfilTileReplan* = enum
|
||||
ttrSelf, ttrOff, ttrEnemy
|
||||
@@ -93,6 +104,33 @@ type
|
||||
rrNone, rrInit, rrTileSelf, rrTileEnemy, rrDanger, rrExpiry,
|
||||
rrArrival, rrHyst ## j144: the tile was REACHED / the alternative won by a margin
|
||||
|
||||
type
|
||||
TfilGeoDim* = enum ## WHAT geometry the draw is shaped by
|
||||
gdoOff = "off", gdoTurn = "turn", gdoDist = "dist", gdoBoth = "both"
|
||||
TfilGeoShape* = enum ## HOW the shape is turned into a draw
|
||||
gfSoft = "soft", gfTopK = "topk", gfRej = "rej"
|
||||
|
||||
proc parseGeo*(s: string): tuple[dim: TfilGeoDim, form: TfilGeoShape] =
|
||||
## `"turn"`, `"dist"`, `"both"` | any of those + `"-soft"` (default) |
|
||||
## `"-topk"` | `"-rej"`. Anything unrecognised, and `"off"`, is OFF = today's
|
||||
## uniform draw. One env name, two axes: a `both-soft`/`both-topk` grid would
|
||||
## need three names for the same three dials.
|
||||
var a = s.strip().toLowerAscii()
|
||||
var form = gfSoft
|
||||
let dash = a.rfind('-')
|
||||
if dash > 0:
|
||||
case a[dash + 1 .. ^1]
|
||||
of "topk": form = gfTopK; a = a[0 ..< dash]
|
||||
of "rej", "rejection": form = gfRej; a = a[0 ..< dash]
|
||||
of "soft": a = a[0 ..< dash] # the default form, spelled out
|
||||
else: discard
|
||||
result.form = form
|
||||
result.dim = case a
|
||||
of "turn": gdoTurn
|
||||
of "dist", "distance": gdoDist
|
||||
of "both": gdoBoth
|
||||
else: gdoOff
|
||||
|
||||
proc tileReplanName*(m: TfilTileReplan): string =
|
||||
case m
|
||||
of ttrSelf: "self"
|
||||
@@ -152,6 +190,78 @@ var
|
||||
## j134: the shared fire-detection correction (`TR_FIRE_FIX`, default on).
|
||||
## Off = the shipped `prev - energy` detector byte-for-byte.
|
||||
TfilFireFix*: bool = true
|
||||
## j134/j147: the env-gated live trace (`TR_FIRE_DIAG`) — one `SPAWN` line per
|
||||
## detected enemy fire, for the ghost-vs-observer position probe. Observability
|
||||
## only; off by default.
|
||||
TfilFireDiag*: bool = false
|
||||
## j150: the picker's hard heat cutoff was a proc-local `const`, so no offline
|
||||
## sweep could move it. Same env-overridable-var pattern as the shape knobs;
|
||||
## the DEFAULT is today's `10.0`, so the default path is bit-identical.
|
||||
## TR_TFIL_DANGER_THRESHOLD default 10.0
|
||||
TfilDangerThreshold* = DefaultDangerThreshold
|
||||
## j152: `TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` — GEOMETRY shapes the DRAW.
|
||||
## Heat still gates the pool with the same hard filter; this only re-weights
|
||||
## the survivors by how far the tile sits from where we are already going.
|
||||
## TR_TFIL_GEO_MODE off | turn | dist | both [+ `-soft` | `-topk` | `-rej`]
|
||||
## TR_TFIL_GEO_TAU deg, 0 = off (= today's uniform draw, exactly)
|
||||
## WHAT IS DIFFERENT FROM j9 (`TR_TFIL_TURN_BIAS`, a live null): that was a
|
||||
## tiebreak WEIGHT applied only among the non-empty safe set. This runs on the
|
||||
## WHOLE pool the draw already runs on, so it also shapes the 2 promoted
|
||||
## least-hot tiles the ~65% forced (safePre < 2) picks choose from.
|
||||
TfilGeoMode*: TfilGeoDim = gdoOff
|
||||
TfilGeoForm*: TfilGeoShape = gfSoft
|
||||
TfilGeoTau*: float = 0.0
|
||||
## j151: `TR_TFIL_ARRIVE_TICKS` — refuse a candidate we cannot REACH inside
|
||||
## the commitment horizon (ticks = dist / MaxSpeed). Hard bound, not a
|
||||
## preference; empty pool => today's full pool, so it can never starve the
|
||||
## draw. 0 (default) = off = byte-for-byte today.
|
||||
TfilArriveTicks*: float = 0.0
|
||||
## j153: `TR_TFIL_HOLD_WHEN_TRAPPED` — when the SAFE set is EMPTY (zero tiles
|
||||
## with `pathMaxHeat <= PathDangerThreshold` inside the reachable hull), STOP
|
||||
## for this tick instead of promoting the 2 least-hot blocked tiles. The
|
||||
## owner's rule: "if no tile is found to go, to not choose the less dangerous,
|
||||
## but to stay still! the next tick probably the situation already changed".
|
||||
## ONE tick only, never latched: the hold is taken at the pick site, and the
|
||||
## pick site only runs when `commitTicks == 0`, so the very next tick
|
||||
## re-evaluates the field from scratch. That is why there is no max-hold knob:
|
||||
## a counter can only add a way to get stuck.
|
||||
## Default false = byte-for-byte today's promote-the-2 behaviour.
|
||||
TfilHoldWhenTrapped*: bool = false
|
||||
## j154: `TR_TFIL_HOLD_MAX_TICKS` — the BOUNDED version of the j153 one-tick
|
||||
## hold. While the safe tile set stays EMPTY the mover holds position for at
|
||||
## most this many ticks (the enemy's own rate of fire bounds what waiting can
|
||||
## buy: see `HoldPanicTicks`). Rules, all four of them load-bearing:
|
||||
## * a safe tile exists -> release on the SAME tick, always
|
||||
## * a tracked bullet reaches us within `min(N, 16)` ticks -> PANIC RELEASE,
|
||||
## the hold is overridden and the normal promote-the-2 fallback resumes
|
||||
## * the counter resets when a safe tile is taken, so the bound is per
|
||||
## empty-streak, not per round
|
||||
## * the gun is untouched: `computeMove` never fires, so a held tick still
|
||||
## fires exactly as every other tick (verified in `test_tfil_commit_env`)
|
||||
## Default 0 = OFF = byte-for-byte today's behaviour, j153 included.
|
||||
TfilHoldMaxTicks*: int = 0
|
||||
## j150: `TR_TFIL_DIAG` — fill `TfilLoss*` with the per-pick LOSS HISTOGRAM
|
||||
## (how many tiles die at each picker stage). Pure counters, off by default.
|
||||
TfilDiag*: bool = false
|
||||
|
||||
## The picker's loss histogram, one entry per PICK. Stage sizes, in picker
|
||||
## order: tiles inside the reachable hull -> survivors of the `CoolestLevels`
|
||||
## distinct-lava-value filter -> survivors of the `pathMaxHeat <= threshold`
|
||||
## filter (counted BEFORE the "keep 2 anyway" promotion) -> what the draw
|
||||
## actually runs on. Pure bookkeeping, read by the offline ruler.
|
||||
type TfilLossStats* = object
|
||||
picks*: int
|
||||
sReach*: int ## inside the reachable hull
|
||||
sCool*: int ## after CoolestLevels (= 2) distinct-value filter
|
||||
sSafe*: int ## after the path heat filter, pre-promotion
|
||||
sCand*: int ## what the draw ran on (post blocked-tile/no-rev)
|
||||
emptySafe*: int ## picks that had to break the heat filter (sSafe < 2)
|
||||
safeHist*: array[8, int] ## sSafe size buckets: 0,1,2-3,4-7,8-15,16-31,32-63,64+
|
||||
rejectedHeat*: seq[float] ## pathMaxHeat of every tile the filter dropped
|
||||
admittedHeat*: seq[float] ## pathMaxHeat of every tile that passed it
|
||||
chosenHeat*: seq[float]
|
||||
|
||||
var TfilLoss*: TfilLossStats
|
||||
|
||||
proc getEnvInt(name: string, default: int): int =
|
||||
let s = getEnv(name, "")
|
||||
@@ -186,6 +296,23 @@ proc loadTfilCommitEnv*() =
|
||||
TfilTurnBias = max(0.0, getEnvFloat("TR_TFIL_TURN_BIAS", 0.0))
|
||||
TfilTurnRefDeg = max(0.0, getEnvFloat("TR_TFIL_TURN_REF_DEG", 45.0))
|
||||
TfilFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||||
TfilFireDiag = existsEnv("TR_FIRE_DIAG")
|
||||
TfilDangerThreshold = max(0.0, getEnvFloat("TR_TFIL_DANGER_THRESHOLD",
|
||||
DefaultDangerThreshold))
|
||||
TfilDiag = getEnvBool("TR_TFIL_DIAG", false)
|
||||
let (gd, gf) = parseGeo(getEnv("TR_TFIL_GEO_MODE", "off"))
|
||||
TfilGeoMode = gd
|
||||
TfilGeoForm = gf
|
||||
TfilGeoTau = max(0.0, getEnvFloat("TR_TFIL_GEO_TAU", 0.0))
|
||||
# j151: hard arrival bound. The draw is UNIFORM over every safe tile inside the
|
||||
# 50-tick reachable hull, so a tile 47 ticks away had the same 1-in-52 chance
|
||||
# as the adjacent one, while the target is only HELD for CommitTicks=15. The
|
||||
# offline ruler (measure_tfil_pick_defects) measured 65% of picks beyond the
|
||||
# 15-tick horizon and a 6.5% arrival rate. 0 = off = today's uniform draw.
|
||||
TfilArriveTicks = max(0.0, getEnvFloat("TR_TFIL_ARRIVE_TICKS", 0.0))
|
||||
TfilHoldWhenTrapped = getEnvBool("TR_TFIL_HOLD_WHEN_TRAPPED", false)
|
||||
TfilHoldMaxTicks = max(0, getEnvInt("TR_TFIL_HOLD_MAX_TICKS", 0))
|
||||
if not TfilDiag: TfilLoss = TfilLossStats()
|
||||
|
||||
loadTfilCommitEnv()
|
||||
|
||||
@@ -264,6 +391,31 @@ proc loadTfilHeatEnv*() =
|
||||
|
||||
loadTfilHeatEnv()
|
||||
|
||||
# ── Corridor LENGTH bound (TR_TFIL_CORRIDOR_TICKS, default 0 = to the wall) ──
|
||||
#
|
||||
# WHY: the shipped corridor is the rotated rectangle from the bullet to the
|
||||
# ARENA WALL, so one distant bullet blankets a 40px-wide swath across the whole
|
||||
# map. That is not physical: in `t` ticks a bullet covers `speed * t` px, and
|
||||
# `speed = 20 - 3*power`, so a fast (low-power) bullet's reach is LONG and a
|
||||
# slow one's is SHORT.
|
||||
#
|
||||
# WHAT: bound the corridor's LENGTH, nothing else — the heat inside the
|
||||
# surviving corridor is EXACTLY today's (`heatDecay(along/speed)` is unchanged),
|
||||
# so unlike the j119 time-indexed heat model this does NOT decay heat along the
|
||||
# corridor; it only removes corridor that no bullet will reach.
|
||||
var TfilCorridorTicks* = 0.0 ## 0 (default) = to the wall: byte-for-byte shipped
|
||||
|
||||
proc loadTfilCorridorEnv*() =
|
||||
TfilCorridorTicks = getEnvFloat("TR_TFIL_CORRIDOR_TICKS", 0.0)
|
||||
|
||||
loadTfilCorridorEnv()
|
||||
|
||||
proc corridorReach*(tWall, speed: float): float =
|
||||
## The ONE place the corridor length is decided, so the heat field and the
|
||||
## drawn outline can never disagree. `tWall` = distance to the wall along the
|
||||
## heading, `speed` = the ghost's own px/tick. 0 = to the wall (shipped).
|
||||
if TfilCorridorTicks <= 0.0: tWall else: min(tWall, speed * TfilCorridorTicks)
|
||||
|
||||
proc heatDecay*(dt: float): float =
|
||||
## Fraction of a bullet's heat still present `dt` ticks before it arrives.
|
||||
## Exactly 1.0 when the time model is off, so the default field is
|
||||
@@ -320,6 +472,12 @@ type
|
||||
## candidate set of the last pick (j145:
|
||||
## lets a caller measure the REGRET of the
|
||||
## draw instead of only the drawn value)
|
||||
lastHeld: bool ## the last tick HELD position
|
||||
## (j153: TR_TFIL_HOLD_WHEN_TRAPPED,
|
||||
## the safe set was empty)
|
||||
holdTicks: int ## j154: ticks HELD in the current
|
||||
## empty-safe-set streak; reset to 0
|
||||
## when a safe tile is taken
|
||||
lastPickSafe: int ## how many SAFE tiles (pathMaxHeat
|
||||
## <= PathDangerThreshold) the last pick
|
||||
## drew from (j146: the size of the set
|
||||
@@ -354,6 +512,7 @@ proc resetRound*(m: var TFILModule) =
|
||||
m.fire.reset()
|
||||
m.commitTicks = 0
|
||||
m.commitAge = 0
|
||||
m.holdTicks = 0 ## j154: the bounded hold never survives a round
|
||||
m.cachedHull = @[]
|
||||
m.cachedInsideTiles = @[]
|
||||
m.blockedTile = (col: 0, row: 0, active: false)
|
||||
@@ -369,6 +528,7 @@ proc resetRound*(m: var TFILModule) =
|
||||
m.picks = 0
|
||||
m.lastPickPromoted = false
|
||||
m.lastPickMinTurn = 0.0
|
||||
m.lastHeld = false
|
||||
m.lastPickSafe = 0
|
||||
|
||||
# ── Commit diagnostics (TR_TFIL_COMMIT_LOG, off by default) ──────────────────
|
||||
@@ -426,11 +586,20 @@ proc spawnTrackedWave(m: var TFILModule, ws: WorldState, ei: EnemyInfo,
|
||||
let heading = arctan2(predY - ei.y, predX - ei.x)
|
||||
if m.bullets.len >= MaxTrackedBullets:
|
||||
m.bullets.del(0) # ponytail: drop oldest; fine for 20-bullet cap
|
||||
# j147: back-date the shot by the measured detection lag (`TR_FIRE_LAG`,
|
||||
# default 0 = untouched). See `movement_harness/fire_tracker.nim`.
|
||||
let vx = speed * cos(heading)
|
||||
let vy = speed * sin(heading)
|
||||
var gx = ei.x
|
||||
var gy = ei.y
|
||||
if FireLag > 0:
|
||||
gx += vx * FireLag.float
|
||||
gy += vy * FireLag.float
|
||||
m.bullets.add TrackedBullet(
|
||||
originX: ei.x, originY: ei.y,
|
||||
x: ei.x, y: ei.y,
|
||||
velX: speed * cos(heading),
|
||||
velY: speed * sin(heading),
|
||||
x: gx, y: gy,
|
||||
velX: vx,
|
||||
velY: vy,
|
||||
power: power,
|
||||
alive: true,
|
||||
age: 0)
|
||||
@@ -451,8 +620,40 @@ proc detectFires(m: var TFILModule, ws: WorldState) =
|
||||
for ei in ws.enemies:
|
||||
for p in m.fire.detect(ei.id, ei.energy, 0.09, 3.01, TfilFireFix):
|
||||
m.spawnTrackedWave(ws, ei, p)
|
||||
if TfilFireDiag and m.bullets.len > 0:
|
||||
# Ghost-vs-observer probe: the tick we DETECTED the fire, our own
|
||||
# position (the timeline anchor) and the ghost's DRAWN position.
|
||||
let b = m.bullets[^1]
|
||||
let sp = 20.0 - 3.0 * p
|
||||
echo "[firediag] SPAWN tick=", ws.tick,
|
||||
" sx=", ws.selfX, " sy=", ws.selfY,
|
||||
" gx=", b.x, " gy=", b.y,
|
||||
" p=", p,
|
||||
" eta=", sqrt((ws.selfX - b.x)^2 + (ws.selfY - b.y)^2) / sp
|
||||
m.fire.endScan()
|
||||
|
||||
proc bulletPanic*(m: TFILModule, selfX, selfY, horizon: float): bool =
|
||||
## PANIC RELEASE (j154). True when a TRACKED bullet's straight path comes
|
||||
## within its own CORE of where WE are at any time in `[0, horizon]` ticks.
|
||||
## Closest approach of a straight ray is `t* = ((self - b) . v) / |v|^2`; the
|
||||
## `t* < 0` case is the `dot < 0` reap in `advanceBullets` (it is already past
|
||||
## us) and `t* > horizon` is "not inside the window". The prediction is the
|
||||
## tracked ghost's OWN position/velocity — the same model `pathMaxHeat` decays
|
||||
## by and `advanceBullets` integrates — so there is no second arrival model in
|
||||
## this file. Exported so the guard test can call it directly.
|
||||
if horizon <= 0.0: return false
|
||||
for b in m.bullets:
|
||||
let v2 = b.velX * b.velX + b.velY * b.velY
|
||||
if v2 < 1e-9: continue
|
||||
let dx = selfX - b.x
|
||||
let dy = selfY - b.y
|
||||
let t = (dx * b.velX + dy * b.velY) / v2
|
||||
if t < 0.0 or t > horizon: continue
|
||||
let mx = dx - t * b.velX
|
||||
let my = dy - t * b.velY
|
||||
if sqrt(mx * mx + my * my) <= bulletRadii(b.power).core: return true
|
||||
false
|
||||
|
||||
proc advanceBullets(m: var TFILModule, selfX, selfY: float) =
|
||||
## Advance positions and reap bullets that are: passed us, out of bounds, or too old.
|
||||
var i = 0
|
||||
@@ -482,6 +683,9 @@ type CorridorGeom = object
|
||||
bx, by: float ## bullet origin
|
||||
|
||||
proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeom =
|
||||
## `tMin` is the corridor LENGTH: the distance to the wall, bounded by
|
||||
## `corridorReach` (TR_TFIL_CORRIDOR_TICKS). Unset (0) -> exactly the wall
|
||||
## distance, so the shipped field and outline are byte-for-byte unchanged.
|
||||
let speed = sqrt(b.velX * b.velX + b.velY * b.velY)
|
||||
if speed < 0.001: return
|
||||
let dx = b.velX / speed
|
||||
@@ -491,6 +695,7 @@ proc corridorGeom(b: TrackedBullet, arenaWidth, arenaHeight: float): CorridorGeo
|
||||
elif dx < 0.0: tMin = min(tMin, (0.0 - b.x) / dx)
|
||||
if dy > 0.0: tMin = min(tMin, (arenaHeight - b.y) / dy)
|
||||
elif dy < 0.0: tMin = min(tMin, (0.0 - b.y) / dy)
|
||||
tMin = corridorReach(tMin, speed)
|
||||
CorridorGeom(dx: dx, dy: dy, px: -dy, py: dx, tMin: tMin, bx: b.x, by: b.y)
|
||||
|
||||
proc lavaAt(m: TFILModule, col, row: int): float =
|
||||
@@ -628,6 +833,76 @@ proc turnWeights*(turns: openArray[float], bias, refDeg: float): seq[int] =
|
||||
result.add max(1, int(round(1.0 + bias *
|
||||
(1.0 - max(0.0, t - refDeg) / 180.0))))
|
||||
|
||||
# ── j152: the geometric DRAW ────────────────────────────────────────────────
|
||||
# The owner: "choose tiles pool not only from the heat point but from a
|
||||
# geometrically position too". Heat is already a HARD filter (unchanged); this is
|
||||
# the second half — the distribution the draw samples from.
|
||||
#
|
||||
# WHY THIS IS NOT j9 AGAIN. j9 (`TR_TFIL_TURN_BIAS`) down-weighted the turn among
|
||||
# the non-empty safe set and measured a live null. This runs on the pool the draw
|
||||
# ALREADY runs on, which for ~65% of picks is the 2 promoted least-hot tiles that
|
||||
# broke the heat filter — j9 could not see those at all.
|
||||
const GeoDegPerTick = 12.0 ## distance cost, in "effective degrees": a 15-tick
|
||||
## trip (the commitment horizon) costs the same as a
|
||||
## 180 deg turn, so ONE tau knob means the same
|
||||
## thing in `turn` and `dist` mode.
|
||||
|
||||
proc geoCosts*(turns, ttas: openArray[float], dim: TfilGeoDim): seq[float] =
|
||||
## Per-candidate cost in effective degrees. Never filters: it only re-orders
|
||||
## and re-weights tiles that already passed the heat filter.
|
||||
for i in 0..<turns.len:
|
||||
result.add (if dim in {gdoTurn, gdoBoth}: turns[i] else: 0.0) +
|
||||
(if dim in {gdoDist, gdoBoth}: ttas[i] * GeoDegPerTick else: 0.0)
|
||||
|
||||
proc geoPick*(turns, ttas: openArray[float], dim: TfilGeoDim, form: TfilGeoShape,
|
||||
tau: float): int =
|
||||
## Draw index from `candidates` under the geometric weight. NEVER returns -1
|
||||
## and NEVER returns an out-of-range index, so no arm can starve the pick.
|
||||
let c = geoCosts(turns, ttas, dim)
|
||||
var best = 0
|
||||
for i in 1..<c.len:
|
||||
if c[i] < c[best]: best = i
|
||||
case form
|
||||
of gfRej:
|
||||
# Rejection sampling: a GEOMETRY-FREE acceptance test (no shape function at
|
||||
# all) — uniform draw, redraw while the candidate costs more than `tau`.
|
||||
# ponytail: 16 tries is a fixed budget; widen it if the band ever tightens
|
||||
# enough that the fallback below starts dominating.
|
||||
for _ in 0..<16:
|
||||
let i = rand(c.high)
|
||||
if c[i] <= tau: return i
|
||||
return best # band too tight: take the best available, never starve
|
||||
of gfTopK:
|
||||
# Hard: keep the best THIRD, uniform inside. Collapses diversity by design —
|
||||
# measured against the soft form before it could ever be a default.
|
||||
let k = max(1, (c.len + 2) div 3)
|
||||
var pool: seq[int]
|
||||
var taken = newSeq[bool](c.len)
|
||||
for _ in 0..<k:
|
||||
var b = -1
|
||||
for i in 0..<c.len:
|
||||
if not taken[i] and (b < 0 or c[i] < c[b]): b = i
|
||||
taken[b] = true
|
||||
pool.add b
|
||||
return pool[rand(pool.high)]
|
||||
of gfSoft:
|
||||
# w = exp(-cost / tau), NORMALISED so the best tile weighs exactly 1.0. The
|
||||
# normalisation is what makes "all tiles tie" (and only that) degrade to the
|
||||
# uniform draw, and makes starvation impossible.
|
||||
var w: seq[float]
|
||||
for x in c: w.add exp(-x / tau)
|
||||
var wMax = 0.0
|
||||
for x in w: wMax = max(wMax, x)
|
||||
if wMax <= 0.0: return best
|
||||
var total = 0.0
|
||||
for x in w: total += x
|
||||
let r = rand(total)
|
||||
var acc = 0.0
|
||||
for i, x in w:
|
||||
acc += x
|
||||
if r < acc: return i
|
||||
return best
|
||||
|
||||
proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
if m.cols == 0:
|
||||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||
@@ -881,6 +1156,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
var pickedInterval = 0
|
||||
var pickedTurn = 0.0 # log-only: |turn| to the tile that was chosen
|
||||
var pickedPromoted = false ## log-only: the pick had to break the heat filter
|
||||
var pickedHeld = false ## j153: the safe set was EMPTY -> hold this tick
|
||||
|
||||
# Hull + inside-tiles: only recompute on replan tick (commitTicks == 0)
|
||||
type TileRef = tuple[col, row: int]
|
||||
@@ -931,11 +1207,13 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
# Score each cool tile by MAX lava on the straight-line path from bot.
|
||||
# A single hot tile on the path (corridor, bullet core, enemy aura) makes the whole path unsafe.
|
||||
const PathSampleStep = 18.0 # ~half a tile
|
||||
const PathDangerThreshold = 10.0 # max lava on path; above this = unsafe
|
||||
# j150: was `const 10.0`; now the env-overridable var whose DEFAULT is 10.0.
|
||||
let PathDangerThreshold = TfilDangerThreshold # max lava on path; above = unsafe
|
||||
# j145: `turnDeg` is the |heading change| from the direction we are ALREADY
|
||||
# travelling to the tile centre. It is carried on the candidate (never folded
|
||||
# into `pathMaxHeat`) so the pick can bias among the safe tiles only.
|
||||
type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float]
|
||||
type ScoredTile = tuple[col, row: int; pathMaxHeat: float; turnDeg: float;
|
||||
arriveTicks: float]
|
||||
|
||||
proc pathMaxHeat(m: TFILModule, fx, fy, tx, ty: float): float =
|
||||
## MAX lava on the straight-line segment (fx,fy) -> (tx,ty), sampled every
|
||||
@@ -976,7 +1254,8 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
scoredTiles.add (col: t.col, row: t.row,
|
||||
pathMaxHeat: pathMaxHeat(m, ws.selfX, ws.selfY, tx, ty),
|
||||
turnDeg: abs(tileOffTravel(m, t.col, t.row, ws.selfX,
|
||||
ws.selfY, travelDeg)))
|
||||
ws.selfY, travelDeg)),
|
||||
arriveTicks: sqrt((tx - ws.selfX)^2 + (ty - ws.selfY)^2) / MaxSpeed)
|
||||
|
||||
# Sort by pathMaxHeat ascending (insertion sort — small N)
|
||||
for i in 1..<scoredTiles.len:
|
||||
@@ -996,8 +1275,12 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
for t in scoredTiles:
|
||||
if t.pathMaxHeat <= PathDangerThreshold: safeTiles.add t
|
||||
else: blockedTiles.add t
|
||||
let safePre = safeTiles.len # j150: the safe set BEFORE the "keep 2" promotion
|
||||
let safeEmpty = safePre == 0
|
||||
if safeTiles.len < 2:
|
||||
# Fallback: promote the least-hot blocked tiles until we have 2
|
||||
# Fallback: promote the least-hot blocked tiles until we have 2. j154 runs
|
||||
# this EVEN when a hold is armed, so the panic release has a real fallback to
|
||||
# fall back ON; the hold only ever DISCARDS the promotion, at the pick site.
|
||||
# ponytail: O(n) scan on already-sorted seq — fine for small N
|
||||
let needed = 2 - safeTiles.len
|
||||
let promote = min(needed, blockedTiles.len)
|
||||
@@ -1006,6 +1289,15 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
m.lastPickPromoted = true
|
||||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||||
|
||||
# j151: the arrival bound, applied to the pool the draw runs on (hysteresis
|
||||
# included) so every consumer sees the same set. It never empties the pool:
|
||||
# if nothing is within the horizon, the full pool is used, exactly as today.
|
||||
if TfilArriveTicks > 0.0:
|
||||
var withinHorizon: seq[ScoredTile]
|
||||
for t in safeTiles:
|
||||
if t.arriveTicks <= TfilArriveTicks: withinHorizon.add t
|
||||
if withinHorizon.len > 0: safeTiles = withinHorizon
|
||||
|
||||
# Commitment logic. With every j144 knob at its default (all off) this is the
|
||||
# original three-way test, unchanged. j144 adds two ways OUT of a commitment
|
||||
# that are NOT a tile crossing, and turns the tick counter into a MINIMUM
|
||||
@@ -1063,7 +1355,31 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
# is the first pick of the round, which is not a switch at all.
|
||||
let midFlight = m.picks > 0 and not atTarget
|
||||
|
||||
if m.commitTicks == 0 and safeTiles.len > 0:
|
||||
# ── j153/j154: THE HOLD, decided here and nowhere else ─────────────────────
|
||||
# The safe set was EMPTY, so there is nothing good to walk to. Today's answer
|
||||
# is the promote-the-2 fallback above; the owner's answer is to stand still and
|
||||
# let the field change. It is decided HERE, after the commitment block, so
|
||||
# "are we on a replan tick?" is already answered — a hold replaces a REPLAN
|
||||
# and can never interrupt a live commitment (j153's comment said that; its
|
||||
# code did not enforce it, and a mid-commitment hold silently froze the bot).
|
||||
var doPick = m.commitTicks == 0 and safeTiles.len > 0
|
||||
if m.commitTicks == 0 and safeEmpty:
|
||||
let budgeted = TfilHoldMaxTicks > 0
|
||||
let hold =
|
||||
if budgeted:
|
||||
# The BOUNDED hold: at most N ticks per empty streak, released the tick
|
||||
# a safe tile exists, and overridden outright by an inbound bullet.
|
||||
m.holdTicks < TfilHoldMaxTicks and
|
||||
not bulletPanic(m, ws.selfX, ws.selfY,
|
||||
min(TfilHoldMaxTicks.float, HoldPanicTicks))
|
||||
else:
|
||||
TfilHoldWhenTrapped # j153's ONE-tick hold, unchanged
|
||||
if hold:
|
||||
pickedHeld = true
|
||||
doPick = false
|
||||
if budgeted: inc m.holdTicks
|
||||
|
||||
if doPick:
|
||||
# Filter out the blocked tile from candidates
|
||||
var candidates: seq[ScoredTile]
|
||||
for t in safeTiles:
|
||||
@@ -1086,7 +1402,20 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
for i in keep: narrowed.add candidates[i]
|
||||
candidates = narrowed
|
||||
var chosen = 0
|
||||
if (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2:
|
||||
# j152: geometry shapes the DRAW, on top of the heat filter (never instead
|
||||
# of it). Off by default: with `TR_TFIL_GEO_MODE=off` this whole block is
|
||||
# skipped and the draw below is byte-for-byte today's.
|
||||
# ponytail: takes precedence over TfilNoRev/TfilTurnBias (both also default
|
||||
# off) instead of composing weights; compose if two are ever armed at once.
|
||||
let geoOn = TfilGeoMode != gdoOff and TfilGeoTau > 0.0
|
||||
if geoOn and candidates.len >= 2:
|
||||
var gturns: seq[float]
|
||||
var gttas: seq[float]
|
||||
for t in candidates:
|
||||
gturns.add t.turnDeg
|
||||
gttas.add t.arriveTicks
|
||||
chosen = geoPick(gturns, gttas, TfilGeoMode, TfilGeoForm, TfilGeoTau)
|
||||
elif (TfilNoRev or TfilTurnBias > 0.0) and candidates.len >= 2:
|
||||
# Soft preferences — down-weight, never filter, and only ever among tiles
|
||||
# that already passed the hard heat filter above:
|
||||
# arm C (TR_TFIL_NO_REV, off by default) — 3:1 forward vs rearward,
|
||||
@@ -1143,6 +1472,7 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
m.commitTicks = TfilCommitTicks
|
||||
m.commitAge = 0
|
||||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||||
m.holdTicks = 0 # j154: a safe tile was taken -> the budget refills
|
||||
m.blockedTile.active = false # clear after successful pick
|
||||
|
||||
# log-only: reversal test against the travel direction
|
||||
@@ -1158,6 +1488,25 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
m.lastPickCall = m.callCount
|
||||
inc m.picks
|
||||
|
||||
if TfilDiag: # j150: where the tiles died, one row per pick. No effect.
|
||||
inc TfilLoss.picks
|
||||
TfilLoss.sReach += insideTiles.len
|
||||
TfilLoss.sCool += coolTiles.len
|
||||
TfilLoss.sSafe += safePre
|
||||
TfilLoss.sCand += candidates.len
|
||||
if safePre < 2: inc TfilLoss.emptySafe
|
||||
let b = (if safePre == 0: 0 elif safePre == 1: 1
|
||||
elif safePre <= 3: 2 elif safePre <= 7: 3
|
||||
elif safePre <= 15: 4 elif safePre <= 31: 5
|
||||
elif safePre <= 63: 6 else: 7)
|
||||
inc TfilLoss.safeHist[b]
|
||||
# admitted/rejected by the FILTER itself, so the promoted (over-threshold)
|
||||
# rescue tiles are not counted as safe.
|
||||
for t in scoredTiles:
|
||||
if t.pathMaxHeat <= PathDangerThreshold: TfilLoss.admittedHeat.add t.pathMaxHeat
|
||||
else: TfilLoss.rejectedHeat.add t.pathMaxHeat
|
||||
TfilLoss.chosenHeat.add ct.pathMaxHeat
|
||||
|
||||
if m.debugGraphics:
|
||||
# Reachable hull perimeter (darker blue)
|
||||
if m.cachedHull.len >= 3:
|
||||
@@ -1230,10 +1579,24 @@ proc computeMove*(m: var TFILModule, ws: WorldState): MoveCommand =
|
||||
",\"reason\":\"" & reasonName(reason) & "\",\"rev\":" &
|
||||
(if pickedRev: "1" else: "0") & ",\"mid\":" &
|
||||
(if pickedMidFlight: "1" else: "0") & ",\"interval\":" & $pickedInterval &
|
||||
",\"hold\":" & (if pickedHeld: "1" else: "0") &
|
||||
",\"ht\":" & $m.holdTicks & ## j154: ticks held in the current streak
|
||||
",\"picks\":" & $m.picks & "}")
|
||||
if pickedThisTick: m.replanReason = rrNone
|
||||
m.lastHeld = pickedHeld
|
||||
|
||||
# ── Steering ─────────────────────────────────────────────────────────────────
|
||||
# j153/j154: HOLD. `speed: 0.0` is how this module already says "stop" (the
|
||||
# already-at-target case below returns the same command), so holding needs no
|
||||
# new signal, and the hold path is byte-identical to the stop path the bot
|
||||
# already emits every time it reaches its dodge tile. The gun is NOT in this
|
||||
# module — computeMove never touches fire, and `ModularBot.nim` aims and
|
||||
# fires from tracked state AFTER `go()` on every tick regardless of the speed
|
||||
# it just commanded — so a held tick still fires exactly as before. Guarded in
|
||||
# `test_tfil_commit_env.nim` (the fire detector still latches a wave on a
|
||||
# held tick).
|
||||
if pickedHeld:
|
||||
return (speed: 0.0, turnRate: 0.0)
|
||||
let stepDx = m.commitTarget.x - ws.selfX
|
||||
let stepDy = m.commitTarget.y - ws.selfY
|
||||
let dist2 = stepDx*stepDx + stepDy*stepDy
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
## TR_TFIL_CORRIDOR_HEAT default 10.0 lava per corridor-overlapping tile
|
||||
## TR_TFIL_WALL_HOTNESS default 15.0 peak wall radiance at a wall tile
|
||||
## TR_MOVEMENT_LOG=1 log band/range-class changes (not/tick)
|
||||
## TR_TFIL_RING_COMMIT_ARRIVAL default off hold the committed tile until we
|
||||
## are ON it (port of tfil's j144 fix)
|
||||
## TR_TFIL_RING_NOREV_SPEED default 0.0 px/tick; below this a mid-flight
|
||||
## switch may not turn the bot around
|
||||
## `TR_TFIL_RANGE_TEMP=0` calls plain `rand(candidates.high)` exactly as the
|
||||
## original mover did, so the same binary can serve as the control arm.
|
||||
##
|
||||
@@ -141,6 +145,36 @@ proc loadTfilRingFireEnv*() =
|
||||
TfilRingFireFix = getEnvBool("TR_FIRE_FIX", true)
|
||||
loadTfilRingFireEnv()
|
||||
|
||||
## ── j165: the ARRIVAL commitment, ported from `the_floor_is_lava.nim` ────────
|
||||
## Same BEHAVIOUR as tfil's `TR_TFIL_COMMIT_ARRIVAL` / `TR_TFIL_NOREV_SPEED`,
|
||||
## RING-SPECIFIC env names so the two forks never share a namespace by accident.
|
||||
## Both default OFF, so the default path stays byte-for-byte today's ring
|
||||
## (proved over the 20026-tick fixture replay in `test_tfil_commit_env.nim`).
|
||||
## TR_TFIL_RING_COMMIT_ARRIVAL 0/1 hold the committed tile until we are
|
||||
## ON it, instead of dropping the
|
||||
## commitment on a tile crossing
|
||||
## TR_TFIL_RING_NOREV_SPEED float while |speed| is below this, a
|
||||
## mid-flight switch to the OPPOSITE
|
||||
## side is refused (0 = off)
|
||||
const
|
||||
RingArriveRadius* = 18.0 ## "we are on the committed tile" — the same 18px
|
||||
## radius `the_floor_is_lava.nim` uses
|
||||
RingHullTicks = 50 ## the reachable-hull planning horizon (the literal
|
||||
## 50 already passed to `computeReachableHull`
|
||||
## below). Past it the committed target is no longer
|
||||
## guaranteed reachable: the stall escape.
|
||||
|
||||
var
|
||||
TfilRingCommitArrival* = false
|
||||
TfilRingNoRevSpeed* = 0.0
|
||||
|
||||
proc loadTfilRingCommitEnv*() =
|
||||
## Read the j165 knobs. Called once at module init; the guard test calls it
|
||||
## again after `putEnv` so the non-default arms run in one process.
|
||||
TfilRingCommitArrival = getEnvBool("TR_TFIL_RING_COMMIT_ARRIVAL", false)
|
||||
TfilRingNoRevSpeed = max(0.0, getEnvFloat("TR_TFIL_RING_NOREV_SPEED", 0.0))
|
||||
loadTfilRingCommitEnv()
|
||||
|
||||
const
|
||||
DefaultRangeLo = 100.0
|
||||
DefaultRangeHi = 200.0
|
||||
@@ -240,6 +274,10 @@ type
|
||||
commitTarget: tuple[x, y: float] ## world coords of committed dodge point
|
||||
commitTicks: int ## ticks remaining on commitment
|
||||
commitLava: float ## lava at commit time (for spike detection)
|
||||
commitAge: int ## j165: ticks since the current target
|
||||
## was picked (0 = just picked)
|
||||
picks: int ## j165: picks made this round; > 0 means
|
||||
## a switch would be MID-FLIGHT
|
||||
blockedTile: tuple[col, row: int; active: bool] ## excluded from next pick after danger replan
|
||||
cachedHull: seq[tuple[x, y: float]]
|
||||
cachedInsideTiles: seq[tuple[col, row: int]]
|
||||
@@ -283,6 +321,8 @@ proc resetRound*(m: var TFILRingModule) =
|
||||
m.bullets = @[]
|
||||
m.fire.reset()
|
||||
m.commitTicks = 0
|
||||
m.commitAge = 0
|
||||
m.picks = 0
|
||||
m.cachedHull = @[]
|
||||
m.cachedInsideTiles = @[]
|
||||
m.blockedTile = (col: 0, row: 0, active: false)
|
||||
@@ -462,6 +502,40 @@ proc computeReachableHull(x0, y0, heading0, speed0,
|
||||
if cur == startIdx: break
|
||||
hull
|
||||
|
||||
proc ringTileOffTravel*(m: TFILRingModule, col, row: int,
|
||||
sx, sy, travelDeg: float): float =
|
||||
## Signed angle in degrees from the travel direction to the tile centre,
|
||||
## folded into (-180, 180]. Verbatim from `the_floor_is_lava.nim`'s
|
||||
## `tileOffTravel`; the ring's `ScoredTile` carries no `turnDeg`, so the
|
||||
## no-reversal pool computes the offsets itself.
|
||||
let tx = m.marginX + (col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (row.float + 0.5) * GridSize
|
||||
result = arctan2(ty - sy, tx - sx) * 180.0 / PI - travelDeg
|
||||
while result > 180.0: result -= 360.0
|
||||
while result < -180.0: result += 360.0
|
||||
|
||||
proc norevPool*(offs: openArray[float], threshold: float): seq[int] =
|
||||
## j165: which candidate tiles may a slow, mid-flight switch take? Verbatim
|
||||
## from `the_floor_is_lava.nim`. `offs` are the signed angles (deg) from the
|
||||
## travel direction to each candidate, `threshold` is the speed gate
|
||||
## (px/tick). Returns the indices NOT more than 90 deg off — the bot does not
|
||||
## have to turn around to reach them. If EVERY candidate is behind us the
|
||||
## reversal is unavoidable, so the single LEAST-bad one is returned (a shallow
|
||||
## turn, not a 180 deg flip): the result is NEVER empty, so the pick can never
|
||||
## be starved. `threshold <= 0` = the knob is off and every candidate stays.
|
||||
if offs.len == 0: return
|
||||
if threshold <= 0.0:
|
||||
for i in 0..<offs.len: result.add i
|
||||
return
|
||||
var keep: seq[int]
|
||||
for i, a in offs:
|
||||
if abs(a) <= 90.0: keep.add i
|
||||
if keep.len > 0: return keep
|
||||
var best = 0
|
||||
for i, a in offs:
|
||||
if abs(a) < abs(offs[best]): best = i
|
||||
@[best]
|
||||
|
||||
proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||
if m.cols == 0:
|
||||
m.initGrid(ws.arenaWidth, ws.arenaHeight)
|
||||
@@ -482,7 +556,13 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||
m.fire.prevEnergySet(ei.id, ei.energy)
|
||||
|
||||
# Tile-change replan: catches gradual displacement that position threshold misses
|
||||
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0):
|
||||
# j165: with TR_TFIL_RING_COMMIT_ARRIVAL the SELF-tile crossing is exactly the
|
||||
# event that must NOT cancel a commitment: crossing a boundary is the very
|
||||
# motion the commitment commands, and at GridSize 36 / speed 8 it fires every
|
||||
# ~5 ticks — which is precisely this fork's CommitTicks. Under the shipped
|
||||
# default (arrival off) this is the original block verbatim.
|
||||
if (not jumped) and (m.callCount > 0) and (m.commitTicks > 0) and
|
||||
not TfilRingCommitArrival:
|
||||
let curTileCol = clamp(int((ws.selfX - m.marginX) / GridSize), 0, m.cols - 1)
|
||||
let curTileRow = clamp(int((ws.selfY - m.marginY) / GridSize), 0, m.rows - 1)
|
||||
if curTileCol != m.lastTileCol or curTileRow != m.lastTileRow:
|
||||
@@ -756,22 +836,50 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||
safeTiles.add blockedTiles[i]
|
||||
blockedTiles = blockedTiles[promote ..< blockedTiles.len]
|
||||
|
||||
# Commitment logic
|
||||
# Commitment logic. With every j165 knob at its default (both off) this is the
|
||||
# original three-way test, unchanged. j165 adds one way OUT of a commitment
|
||||
# that is NOT a tile crossing (the block above is skipped when armed) and turns
|
||||
# the tick counter into a MINIMUM dwell: the target is held until we are
|
||||
# actually standing on it.
|
||||
let atTarget = (ws.selfX - m.commitTarget.x)^2 + (ws.selfY - m.commitTarget.y)^2 <
|
||||
RingArriveRadius * RingArriveRadius
|
||||
if m.commitTicks > 0:
|
||||
# Only allow danger replan after MinCommitTicks have elapsed
|
||||
inc m.commitAge
|
||||
# Only allow a replan after MinCommitTicks have elapsed
|
||||
let ticksElapsed = CommitTicks - m.commitTicks
|
||||
if ticksElapsed >= MinCommitTicks:
|
||||
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||||
let curLava = m.lavaAt(cc, cr)
|
||||
var commitEnd = false
|
||||
if curLava > m.commitLava + DangerReplanThreshold:
|
||||
# Mark committed tile blocked so we don't re-pick it
|
||||
# GENUINE DANGER: the committed tile got hot. Block it so we don't
|
||||
# immediately re-pick it, and replan. This safety valve is deliberately
|
||||
# independent of the arrival rule and is UNCHANGED by j165.
|
||||
m.blockedTile = (col: cc, row: cr, active: true)
|
||||
m.commitTicks = 0 # replan
|
||||
else:
|
||||
commitEnd = true
|
||||
elif TfilRingCommitArrival:
|
||||
if atTarget:
|
||||
# Reached. Only now is a new target allowed.
|
||||
commitEnd = true
|
||||
elif m.commitAge >= RingHullTicks:
|
||||
# Stall escape: past the planner's own reachability horizon the
|
||||
# committed tile is no longer guaranteed reachable (rammed, boxed in).
|
||||
commitEnd = true
|
||||
if not commitEnd:
|
||||
dec m.commitTicks
|
||||
if m.commitTicks == 0 and TfilRingCommitArrival:
|
||||
m.commitTicks = CommitTicks # minimum dwell reached: renew, don't abandon
|
||||
else:
|
||||
m.commitTicks = 0
|
||||
else:
|
||||
dec m.commitTicks
|
||||
|
||||
# j165: was the commitment we are about to replace still UNREACHED? A pick
|
||||
# that replaces a target we had not yet got to is the owner's failure mode:
|
||||
# the bot is still accelerating and the target flips under it. `picks == 0`
|
||||
# means this is the first pick of the round, which is not a switch at all.
|
||||
let midFlight = m.picks > 0 and not atTarget
|
||||
|
||||
if m.commitTicks == 0 and safeTiles.len > 0:
|
||||
# Filter out the blocked tile from candidates
|
||||
var candidates: seq[ScoredTile]
|
||||
@@ -780,6 +888,22 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||
continue
|
||||
candidates.add t
|
||||
if candidates.len == 0: candidates = safeTiles # all blocked → ignore block
|
||||
# j165, no opposite-direction flip while still accelerating. Below the speed
|
||||
# threshold the bot physically cannot complete a reversal before the bullet
|
||||
# lands, so a mid-flight switch to the mirror side only destroys the dodge it
|
||||
# already has. It is refused outright — and only for a MID-FLIGHT switch: if
|
||||
# we are already standing on the committed tile (an arrival pick) the bot is
|
||||
# free to go anywhere, and that is exactly the pick that must not be blocked.
|
||||
if TfilRingNoRevSpeed > 0.0 and abs(ws.selfSpeed) < TfilRingNoRevSpeed and midFlight:
|
||||
let travelDeg = if ws.selfSpeed < -0.01: ws.selfHeading + 180.0
|
||||
else: ws.selfHeading
|
||||
var offs: seq[float]
|
||||
for t in candidates:
|
||||
offs.add ringTileOffTravel(m, t.col, t.row, ws.selfX, ws.selfY, travelDeg)
|
||||
let keep = norevPool(offs, TfilRingNoRevSpeed)
|
||||
var narrowed: seq[ScoredTile]
|
||||
for i in keep: narrowed.add candidates[i]
|
||||
candidates = narrowed
|
||||
# Safety is a HARD constraint: the weighting below only re-orders the draw
|
||||
# AMONG `candidates`, which is exactly the pool the old `rand` picked from.
|
||||
# It can never select a tile the unweighted code would have rejected
|
||||
@@ -801,6 +925,8 @@ proc computeMove*(m: var TFILRingModule, ws: WorldState): MoveCommand =
|
||||
y: m.marginY + (ct.row.float + 0.5) * GridSize)
|
||||
m.commitTicks = CommitTicks
|
||||
m.commitLava = m.lavaAt(ct.col, ct.row)
|
||||
m.commitAge = 0
|
||||
inc m.picks
|
||||
m.blockedTile.active = false # clear after successful pick
|
||||
|
||||
# One concise log line on a range-class or band change (never per-tick).
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# j177 gun-path default-parity golden.
|
||||
# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with
|
||||
# TR_CAPTURE_AIM unset, over the whole tr_drussgt_vs_modularbot.jsonl.
|
||||
# Format: <gun> shots=<n> hits=<n>, one line per rack gun
|
||||
HeadOn shots=400 hits=59
|
||||
Linear shots=400 hits=47
|
||||
Tsetlin shots=400 hits=74
|
||||
Circular shots=400 hits=51
|
||||
GuessFactor shots=400 hits=44
|
||||
Pattern shots=400 hits=46
|
||||
WallBounce shots=400 hits=55
|
||||
Accel shots=400 hits=59
|
||||
StopShot shots=400 hits=84
|
||||
Displace shots=400 hits=46
|
||||
AvgLead shots=400 hits=51
|
||||
DecayGF shots=400 hits=47
|
||||
KNN shots=400 hits=29
|
||||
TMSelect shots=0 hits=0
|
||||
+20031
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ghost-vs-observer probe (j147) — is the 1-tick aura lag a DECISION lag?
|
||||
|
||||
For every enemy fire in the recorded event sidecar we locate the ghost our
|
||||
mover spawned for it (the `[firediag] SPAWN` line) and compare
|
||||
|
||||
* WHEN the ghost was spawned (its tick) vs WHEN the enemy fired, and
|
||||
* WHERE the ghost was drawn vs WHERE the true bullet is at that instant.
|
||||
|
||||
Timeline anchoring is done in ABSOLUTE arena coordinates: the bot logs its own
|
||||
position at the spawn tick, and the capture row carrying that position pins
|
||||
`captureRow = botTick + k` (a per-round constant = the server's delivery
|
||||
offset). With k known,
|
||||
|
||||
trueBullet(row) = fireOrigin + (row - fireRow) * v (v = 20 - 3*power)
|
||||
ghost(row) = spawnGhostPos + (row - spawnRow) * v
|
||||
|
||||
so both the time lag and the pixel displacement are directly measurable.
|
||||
"""
|
||||
import json, math, re, sys, collections
|
||||
|
||||
SPAWN_RE = re.compile(
|
||||
r"SPAWN tick=(\d+) sx=([-\d.eE+]+) sy=([-\d.eE+]+) gx=([-\d.eE+]+) "
|
||||
r"gy=([-\d.eE+]+) p=([-\d.eE+]+) eta=([-\d.eE+]+)")
|
||||
|
||||
|
||||
def load(arm_dir):
|
||||
rows = [json.loads(l) for l in open(f"{arm_dir}/run1.jsonl") if '"tick"' in l]
|
||||
ev = [json.loads(l) for l in open(f"{arm_dir}/run1.events.jsonl") if l.strip()]
|
||||
rounds = json.load(open(f"{arm_dir}/run1.jsonl.rounds.json"))["rounds"]
|
||||
spawns = []
|
||||
for line in open(f"{arm_dir}/run1.bot.stdout.log"):
|
||||
m = SPAWN_RE.search(line)
|
||||
if m:
|
||||
g = [float(x) for x in m.groups()]
|
||||
spawns.append(dict(tick=int(g[0]), sx=g[1], sy=g[2], gx=g[3], gy=g[4],
|
||||
p=g[5], eta=g[6]))
|
||||
return rows, ev, rounds, spawns
|
||||
|
||||
|
||||
def split_rounds(spawns):
|
||||
"""The bot's tick restarts every round -> segment on a tick decrease."""
|
||||
out, cur = [], []
|
||||
for s in spawns:
|
||||
if cur and s["tick"] <= cur[-1]["tick"]:
|
||||
out.append(cur); cur = []
|
||||
cur.append(s)
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def analyse(arm_dir, verbose=True):
|
||||
rows, ev, rounds, spawns = load(arm_dir)
|
||||
pos = {r["tick"]: r for r in rows}
|
||||
starts = {r["round"]: r["startTick"] for r in rounds}
|
||||
counts = {r["round"]: r["count"] for r in rounds}
|
||||
|
||||
# who is the enemy, and which capture row does a fire turn correspond to
|
||||
votes = collections.Counter()
|
||||
for e in ev:
|
||||
if e["type"] != "fire":
|
||||
continue
|
||||
gt = starts[e["round"]] + e["tick"]
|
||||
for off in (-3, -2, -1, 0, 1):
|
||||
d = pos.get(gt + off)
|
||||
if not d:
|
||||
continue
|
||||
de = math.hypot(d["ex"] - e["x"], d["ey"] - e["y"])
|
||||
ds = math.hypot(d["sx"] - e["x"], d["sy"] - e["y"])
|
||||
votes[(e["owner"], "e" if de < ds else "s")] += 1
|
||||
break
|
||||
enemy_owner = max(votes.items(), key=lambda kv: kv[1])[0][0]
|
||||
fires = [e for e in ev if e["type"] == "fire" and e["owner"] == enemy_owner]
|
||||
by_round = collections.defaultdict(list)
|
||||
for e in fires:
|
||||
by_round[e["round"]].append(e)
|
||||
|
||||
recs, unmatched = [], 0
|
||||
chunks = split_rounds(spawns)
|
||||
# which capture row holds a given position (the bot's own, for the anchor)
|
||||
where = collections.defaultdict(list)
|
||||
for r in rows:
|
||||
where[(round(r["sx"], 3), round(r["sy"], 3))].append(r["tick"])
|
||||
for chunk in chunks:
|
||||
for s in chunk:
|
||||
# TIMELINE, no guessing. MEASURED LIVE (`[firediag] EV hit` lines vs
|
||||
# the capture's event sidecar, exact matches): the sidecar's per-round
|
||||
# `tick` IS the server getTurn and the bot runs with
|
||||
# `getTurn = bot.tick + 1` (j134), so a ghost logged at bot tick `t`
|
||||
# was placed during server turn `t + 1` of its round. The round is
|
||||
# found from the bot's OWN logged position (unique per round).
|
||||
hits = where.get((round(s["sx"], 3), round(s["sy"], 3)), ())
|
||||
rnd = None
|
||||
for hrow in hits:
|
||||
for r_ in rounds:
|
||||
if r_["startTick"] <= hrow < r_["startTick"] + r_["count"]:
|
||||
rnd = r_["round"]
|
||||
break
|
||||
if rnd is not None:
|
||||
break
|
||||
if rnd is None:
|
||||
unmatched += 1
|
||||
continue
|
||||
sturn = s["tick"] + 1
|
||||
cands = []
|
||||
for e in by_round[rnd]:
|
||||
if abs(e["tick"] - sturn) > 2 or abs(e["power"] - s["p"]) > 1e-6:
|
||||
continue
|
||||
oerr = math.hypot(s["gx"] - e["x"], s["gy"] - e["y"])
|
||||
if oerr < 32.0: # the scanned enemy IS the shooter
|
||||
cands.append((abs(e["tick"] - sturn), oerr, e, e["tick"]))
|
||||
if not cands:
|
||||
unmatched += 1
|
||||
continue
|
||||
cands.sort()
|
||||
_, oerr, e, eturn = cands[0]
|
||||
sp = 20.0 - 3.0 * e["power"]
|
||||
th = math.radians(e["dir"])
|
||||
vx, vy = sp * math.cos(th), sp * math.sin(th)
|
||||
d = sturn - eturn # +ve = ghost spawned LATE
|
||||
# a bullet takes its FIRST step during the turn it is fired, so at
|
||||
# the start of server turn `eturn + n` the true bullet sits at
|
||||
# origin + n * v.
|
||||
tx, ty = e["x"] + vx * d, e["y"] + vy * d
|
||||
# arrival deadline: the mover's own eta (logged) vs the true remaining
|
||||
# flight time to the TRUE bullet from the same reference point.
|
||||
etaTrue = math.hypot(s["sx"] - tx, s["sy"] - ty) / sp
|
||||
recs.append(dict(round=rnd, tick=s["tick"], lag=d, power=e["power"],
|
||||
speed=sp, origin_err=oerr, eta=s["eta"],
|
||||
err=math.hypot(s["gx"] - tx, s["gy"] - ty),
|
||||
lagerr=abs(s["eta"] - etaTrue)))
|
||||
|
||||
if verbose:
|
||||
print(f"\n=== {arm_dir}")
|
||||
print(f"enemy owner id={enemy_owner} spawns={len(spawns)} matched={len(recs)}"
|
||||
f" unmatched={unmatched}")
|
||||
if not recs:
|
||||
return None, []
|
||||
print("DETECTION LAG (capture row of the spawn - capture row of the fire),"
|
||||
" +ve = detected LATE:")
|
||||
for lag, n in sorted(collections.Counter(r["lag"] for r in recs).items()):
|
||||
print(f" lag={lag:+d} ticks : {n:4d} ({100.0*n/len(recs):5.1f}%)")
|
||||
errs = sorted(r["err"] for r in recs)
|
||||
n = len(errs)
|
||||
print(f"GHOST-vs-TRUTH displacement px: mean={sum(errs)/n:.2f} median={errs[n//2]:.2f}"
|
||||
f" p90={errs[int(0.9*n)]:.2f} max={errs[-1]:.2f}")
|
||||
oe = sorted(r["origin_err"] for r in recs)
|
||||
print(f" of which ghost ORIGIN vs fire origin (the scanned enemy position):"
|
||||
f" mean={sum(oe)/n:.2f} median={oe[n//2]:.2f} max={oe[-1]:.2f}")
|
||||
# the pure time part: lag * speed
|
||||
pure = sorted(abs(r["lag"]) * r["speed"] for r in recs)
|
||||
print(f"TIME part only (|lag| * speed): mean={sum(pure)/n:.2f} "
|
||||
f"median={pure[n//2]:.2f} max={pure[-1]:.2f}")
|
||||
le = sorted(r["lagerr"] for r in recs)
|
||||
print(f"ARRIVAL-DEADLINE error (mover's eta - the true remaining flight),"
|
||||
f" ticks: mean={sum(le)/n:.3f} median={le[n//2]:.3f}"
|
||||
f" p90={le[int(0.9*n)]:.3f} max={le[-1]:.3f}")
|
||||
return None, recs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for d in sys.argv[1:]:
|
||||
analyse(d)
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""j162 DECISIVE measurement: does the bot ever actually run out of energy?
|
||||
|
||||
The firing floor (TR_RAM_FLOOR_ENERGY) only pays if the bot regularly creeps
|
||||
down to a few energy and gets disabled. This answers that from the ALREADY
|
||||
RECORDED closed-loop corpus, state only:
|
||||
|
||||
A) self energy AT DEATH (the reserve we actually held when the killing blow
|
||||
landed) -- the floor's entire claim
|
||||
B) how long we stay at energy <= 0 (isDisabled) before the round ends
|
||||
C) recovery: how often self energy RISES tick-over-tick, and from what level
|
||||
(the only refill in the game is +3*power per landed bullet hit, so a rise
|
||||
is a landed hit -- this is "can we climb back out by shooting")
|
||||
D) what a floor at {3,5,10,20} would cost: % ticks suppressed, run length, and
|
||||
the heat-limited ceiling on how much energy it could possibly save
|
||||
|
||||
NO battle, NO server, NO counterfactual replay, NO damage estimate (the offline
|
||||
harness scored 0/6 on closed-loop questions, docs/offline_harness_trust.md).
|
||||
|
||||
Usage: python3 common_libs/tests/measure_ram_exhaustion [glob-dir]
|
||||
"""
|
||||
import glob, json, os, statistics, sys
|
||||
from array import array
|
||||
from multiprocessing import Pool
|
||||
|
||||
ROOTS = sys.argv[1:] or ["/tmp"]
|
||||
|
||||
# Tank Royale gun heat: heat += 1 + power/5 and the gun cools 0.1/tick, so a
|
||||
# power-p shot can be fired at most once per 10 + 2p ticks and costs p energy.
|
||||
# The cost bracket is therefore p/(10+2p) energy per tick, from 0.0098 at the
|
||||
# cheapest legal shot (0.1) to 0.1875 at the most expensive (3.0).
|
||||
def per_tick(power):
|
||||
return power / (10.0 + 2.0 * power)
|
||||
|
||||
|
||||
def num(line, key):
|
||||
i = line.find('"' + key + '":')
|
||||
if i < 0:
|
||||
return None
|
||||
i += len(key) + 3
|
||||
j = line.find(',', i)
|
||||
if j < 0:
|
||||
j = line.find('}', i)
|
||||
try:
|
||||
return float(line[i:j])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def load(path):
|
||||
"""[(self, enemy)] per tick, with round boundaries from the round map."""
|
||||
rows = []
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
if '"tick"' not in line:
|
||||
continue
|
||||
t, se, ee = num(line, 'tick'), num(line, 'se'), num(line, 'ee')
|
||||
if t is None or se is None or ee is None:
|
||||
continue
|
||||
rows.append((t, se, ee))
|
||||
if not rows:
|
||||
return []
|
||||
rf = path.replace(".jsonl", ".jsonl.rounds.json")
|
||||
bounds = []
|
||||
if os.path.exists(rf):
|
||||
try:
|
||||
for r in json.load(open(rf))["rounds"]:
|
||||
bounds.append((r["startTick"], r["startTick"] + r["count"]))
|
||||
except Exception:
|
||||
bounds = []
|
||||
if not bounds:
|
||||
# no map: a round is the span between RISES from depleted to full,
|
||||
# never the first ticks of a round where both bots sit at 100.
|
||||
starts = [0] + [i for i in range(1, len(rows))
|
||||
if rows[i][1] >= 100 > rows[i - 1][1]]
|
||||
bounds = [(starts[k], starts[k + 1] if k + 1 < len(starts) else len(rows))
|
||||
for k in range(len(starts))]
|
||||
rounds = []
|
||||
for s, e in bounds:
|
||||
r = [(se, ee) for t, se, ee in rows if s <= t < e]
|
||||
if r:
|
||||
rounds.append(r)
|
||||
return rounds
|
||||
|
||||
|
||||
def corpus():
|
||||
files = []
|
||||
for root in ROOTS:
|
||||
for f in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
|
||||
if f.endswith(".events.jsonl"):
|
||||
continue
|
||||
try:
|
||||
with open(f) as fh:
|
||||
first = fh.readline()
|
||||
except OSError:
|
||||
continue
|
||||
if '"closed_loop":true' not in first.replace(" ", ""):
|
||||
continue
|
||||
files.append(f)
|
||||
out = []
|
||||
for r in Pool(8).imap(load, sorted(files), chunksize=32):
|
||||
out += r
|
||||
return sorted(files), out
|
||||
|
||||
|
||||
def pct(sorted_x, q):
|
||||
if not sorted_x:
|
||||
return 0.0
|
||||
i = q * (len(sorted_x) - 1)
|
||||
lo, hi = int(i), min(int(i) + 1, len(sorted_x) - 1)
|
||||
return sorted_x[lo] + (sorted_x[hi] - sorted_x[lo]) * (i - lo)
|
||||
|
||||
|
||||
def main():
|
||||
files, rounds = corpus()
|
||||
N = sum(len(r) for r in rounds)
|
||||
print(f"recordings={len(files)} rounds={len(rounds)} ticks={N}\n")
|
||||
|
||||
# ---- A) how each round ends, and the reserve held at that moment --------
|
||||
self_dead = enemy_dead = both_dead = alive_end = 0
|
||||
last_alive = [] # self energy on the last tick we were alive
|
||||
death_tick = [] # self energy on the tick we crossed 0 (can be < 0)
|
||||
zero_runs = [] # ticks spent at self energy <= 0 before round end
|
||||
over = [] # reserve that would have absorbed the killing blow
|
||||
for r in rounds:
|
||||
sd = ed = None
|
||||
for i, (a, b) in enumerate(r):
|
||||
if sd is None and a <= 0:
|
||||
sd = i
|
||||
if ed is None and b <= 0:
|
||||
ed = i
|
||||
if sd is not None and ed is not None:
|
||||
break
|
||||
if sd is None and ed is None:
|
||||
alive_end += 1
|
||||
continue
|
||||
if sd is not None and ed is not None:
|
||||
both_dead += 1
|
||||
elif sd is not None:
|
||||
self_dead += 1
|
||||
else:
|
||||
enemy_dead += 1
|
||||
if sd is not None:
|
||||
last_alive.append(r[sd - 1][0] if sd > 0 else r[0][0])
|
||||
death_tick.append(r[sd][0])
|
||||
over.append(-r[sd][0])
|
||||
j = len(r)
|
||||
while j > sd and r[j - 1][0] <= 0:
|
||||
j -= 1
|
||||
zero_runs.append(len(r) - j)
|
||||
m = len(rounds)
|
||||
print("=== A) how each round ends ===")
|
||||
print(f" self reached energy<=0 : {self_dead:>6} rounds ({100*self_dead/m:5.1f}%)")
|
||||
print(f" only the enemy did : {enemy_dead:>6} rounds ({100*enemy_dead/m:5.1f}%)")
|
||||
print(f" both in the same round : {both_dead:>6} rounds ({100*both_dead/m:5.1f}%)")
|
||||
print(f" neither (truncated) : {alive_end:>6} rounds ({100*alive_end/m:5.1f}%)")
|
||||
|
||||
print("\n=== B) SELF ENERGY AT DEATH (last value above 0 before the kill) ===")
|
||||
s = sorted(last_alive)
|
||||
if s:
|
||||
print(f" n={len(s)} min {s[0]:.2f} p10 {pct(s,.10):.2f} median {pct(s,.5):.2f}"
|
||||
f" mean {statistics.fmean(s):.2f} p90 {pct(s,.90):.2f} max {s[-1]:.2f}")
|
||||
for t in (0, 1, 3, 5, 10, 20):
|
||||
c = sum(1 for x in s if x <= t)
|
||||
print(f" <= {t:>2} energy: {c:>6} ({100*c/len(s):5.1f}% of self deaths,"
|
||||
f" {100*c/m:5.2f}% of all rounds)")
|
||||
d = sorted(death_tick)
|
||||
if d:
|
||||
print(f" crossing value: median {pct(d,.5):.2f} p10 {pct(d,.10):.2f}"
|
||||
f" p90 {pct(d,.90):.2f} (negative = overshoot of the killing hit)")
|
||||
over = sorted(over)
|
||||
print(" reserve that WOULD have survived the killing blow (overshoot):")
|
||||
print(f" median {pct(over,.5):.2f} p75 {pct(over,.75):.2f}"
|
||||
f" p90 {pct(over,.90):.2f} p99 {pct(over,.99):.2f} max {over[-1]:.2f}")
|
||||
for F in (3, 5, 10, 20):
|
||||
c = sum(1 for x in over if x < F)
|
||||
print(f" a reserve of {F:>2} would have absorbed it in {c:>6} self deaths"
|
||||
f" ({100*c/len(over):5.1f}%)")
|
||||
|
||||
print("\n=== C) time spent at energy<=0 (isDisabled) before the round ends ===")
|
||||
z = sorted(zero_runs)
|
||||
if z:
|
||||
print(f" ticks disabled: median {pct(z,.5):.0f} p90 {pct(z,.9):.0f}"
|
||||
f" max {z[-1]} total {sum(z)} of {N} ticks"
|
||||
f" ({100*sum(z)/N:.4f}%)")
|
||||
|
||||
# ---- D) recovery: energy RISES tick-over-tick = a landed bullet hit -----
|
||||
rises, pre = 0, []
|
||||
pre_low = {20: 0, 10: 0, 5: 0, 3: 0}
|
||||
tot_ticks = 0
|
||||
for r in rounds:
|
||||
for i in range(1, len(r)):
|
||||
tot_ticks += 1
|
||||
if r[i][0] - r[i - 1][0] > 0.01:
|
||||
rises += 1
|
||||
pre.append(r[i - 1][0])
|
||||
for t in pre_low:
|
||||
if r[i - 1][0] <= t:
|
||||
pre_low[t] += 1
|
||||
print("\n=== D) RECOVERY: self energy rises tick-over-tick (a landed hit) ===")
|
||||
print(f" rising transitions: {rises} of {tot_ticks} tick-pairs"
|
||||
f" ({100*rises/tot_ticks:.3f}%), i.e. ~{rises/len(rounds):.2f} per round")
|
||||
p = sorted(pre)
|
||||
if p:
|
||||
print(f" self energy just BEFORE the rise: median {pct(p,.5):.2f}"
|
||||
f" p10 {pct(p,.10):.2f} p90 {pct(p,.90):.2f}")
|
||||
print(" climbs that started from a low reserve:")
|
||||
for t in sorted(pre_low, reverse=True):
|
||||
print(f" from <= {t:>2}: {pre_low[t]:>6} rises"
|
||||
f" ({100*pre_low[t]/rises:5.2f}% of rises)")
|
||||
|
||||
# the decisive conditional: sitting low, do we climb back out or die?
|
||||
# "death" counts the ONE tick that crosses 0. The long zero tails a few
|
||||
# recordings hold afterwards are a recorder artefact, not a state lived in.
|
||||
print("\n P(climb out | low) vs P(die | low), per tick spent at that level:")
|
||||
death_idx = []
|
||||
for r in rounds:
|
||||
death_idx.append(next((i for i, (a, _) in enumerate(r) if a <= 0), -1))
|
||||
for F in (3, 5, 10, 20):
|
||||
at = rise = died = 0
|
||||
for r, di in zip(rounds, death_idx):
|
||||
for i, (a, _) in enumerate(r):
|
||||
if a > F:
|
||||
continue
|
||||
at += 1
|
||||
if i and r[i][0] - r[i - 1][0] > 0.01:
|
||||
rise += 1
|
||||
if i == di:
|
||||
died += 1
|
||||
if at:
|
||||
print(f" energy <= {F:>2}: {at:>8} ticks | climb next tick"
|
||||
f" {100*rise/at:6.3f}% | killed on this tick {100*died/at:6.3f}%"
|
||||
f" -> dying is {died/max(1,rise):.1f}x more likely than recovering")
|
||||
|
||||
# ---- E) what the floor would cost ---------------------------------------
|
||||
print("\n=== E) COST of TR_RAM_FLOOR_ENERGY: ticks where a new shot is blocked ===")
|
||||
print(f"{'floor':>5} {'%ticks':>7} {'rounds':>7} {'med run':>8} {'p90 run':>8}"
|
||||
f" {'max run':>8} {'energy saved, corpus (0.1..3.0 p)':>34}"
|
||||
f" {'per med run @1.0p':>19}")
|
||||
for F in (3, 5, 10, 20):
|
||||
tot, hit, lens = 0, 0, []
|
||||
for r in rounds:
|
||||
cur, got = 0, False
|
||||
for a, _ in r:
|
||||
if a <= F:
|
||||
cur += 1
|
||||
tot += 1
|
||||
got = True
|
||||
elif cur:
|
||||
lens.append(cur)
|
||||
cur = 0
|
||||
if cur:
|
||||
lens.append(cur)
|
||||
hit += 1 if got else 0
|
||||
lens.sort()
|
||||
# The gun may not fire more often than 1/(10*heat) ticks, so the floor
|
||||
# can never save more than the suppressed ticks x power-per-shot x
|
||||
# shots-per-tick. Report the bracket: 0.1 power (cheapest legal shot) to
|
||||
# 3.0 power (most expensive legal shot).
|
||||
med = pct(lens, .5) if lens else 0
|
||||
lo, hi = tot * per_tick(0.1), tot * per_tick(3.0)
|
||||
mid = med * per_tick(1.0)
|
||||
print(f"{F:>5} {100*tot/N:>6.2f}% {hit:>7} {med:>8.0f} "
|
||||
f"{pct(lens,.9) if lens else 0:>8.0f} {lens[-1] if lens else 0:>8}"
|
||||
f" {lo:>7.0f} .. {hi:>7.0f} {mid:>6.2f}")
|
||||
print(" energy saved over the WHOLE corpus, heat-limited: the 0.1..3.0 power")
|
||||
print(" bracket, then the p=1.0 column = what one median suppressed RUN is worth")
|
||||
print(" (1.0 power is the mode of the measured landed-hit histogram).")
|
||||
print(" Median run lengths 34/53/89/139 ticks; one 1.0-power landed hit = 3.0.")
|
||||
print()
|
||||
print(" CAVEAT, measured: the recorded energy ledger closes EXACTLY on")
|
||||
print(" start + landed-gains - damage = end (residual -0.00 over 35065 rounds),")
|
||||
print(" i.e. these captures DO NOT charge the firepower cost. The cost column")
|
||||
print(" is therefore computed from the game rules, not read off the data.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
recordings=8149 rounds=35163 ticks=34461805
|
||||
|
||||
=== A) how each round ends ===
|
||||
self reached energy<=0 : 21518 rounds ( 61.2%)
|
||||
only the enemy did : 12906 rounds ( 36.7%)
|
||||
both in the same round : 347 rounds ( 1.0%)
|
||||
neither (truncated) : 392 rounds ( 1.1%)
|
||||
|
||||
=== B) SELF ENERGY AT DEATH (last value above 0 before the kill) ===
|
||||
n=21865 min 0.00 p10 0.10 median 0.83 mean 2.50 p90 8.90 max 24.83
|
||||
<= 0 energy: 0 ( 0.0% of self deaths, 0.00% of all rounds)
|
||||
<= 1 energy: 12217 ( 55.9% of self deaths, 34.74% of all rounds)
|
||||
<= 3 energy: 16793 ( 76.8% of self deaths, 47.76% of all rounds)
|
||||
<= 5 energy: 18420 ( 84.2% of self deaths, 52.38% of all rounds)
|
||||
<= 10 energy: 20290 ( 92.8% of self deaths, 57.70% of all rounds)
|
||||
<= 20 energy: 21864 (100.0% of self deaths, 62.18% of all rounds)
|
||||
crossing value: median -0.40 p10 -6.90 p90 0.00 (negative = overshoot of the killing hit)
|
||||
reserve that WOULD have survived the killing blow (overshoot):
|
||||
median 0.40 p75 2.00 p90 6.90 p99 15.00 max 19.50
|
||||
a reserve of 3 would have absorbed it in 17301 self deaths ( 79.1%)
|
||||
a reserve of 5 would have absorbed it in 18610 self deaths ( 85.1%)
|
||||
a reserve of 10 would have absorbed it in 20690 self deaths ( 94.6%)
|
||||
a reserve of 20 would have absorbed it in 21865 self deaths (100.0%)
|
||||
|
||||
=== C) time spent at energy<=0 (isDisabled) before the round ends ===
|
||||
ticks disabled: median 1 p90 18 max 452 total 593030 of 34461805 ticks (1.7208%)
|
||||
|
||||
=== D) RECOVERY: self energy rises tick-over-tick (a landed hit) ===
|
||||
rising transitions: 205754 of 34426642 tick-pairs (0.598%), i.e. ~5.85 per round
|
||||
self energy just BEFORE the rise: median 45.24 p10 8.04 p90 89.00
|
||||
climbs that started from a low reserve:
|
||||
from <= 20: 52256 rises (25.40% of rises)
|
||||
from <= 10: 25352 rises (12.32% of rises)
|
||||
from <= 5: 12812 rises ( 6.23% of rises)
|
||||
from <= 3: 8013 rises ( 3.89% of rises)
|
||||
|
||||
P(climb out | low) vs P(die | low), per tick spent at that level:
|
||||
energy <= 3: 2633881 ticks | climb next tick 0.130% | killed on this tick 0.830% -> dying is 6.4x more likely than recovering
|
||||
energy <= 5: 3299807 ticks | climb next tick 0.232% | killed on this tick 0.663% -> dying is 2.9x more likely than recovering
|
||||
energy <= 10: 5002524 ticks | climb next tick 0.365% | killed on this tick 0.437% -> dying is 1.2x more likely than recovering
|
||||
energy <= 20: 8526129 ticks | climb next tick 0.500% | killed on this tick 0.256% -> dying is 0.5x more likely than recovering
|
||||
|
||||
=== E) COST of TR_RAM_FLOOR_ENERGY: ticks where a new shot is blocked ===
|
||||
floor %ticks rounds med run p90 run max run energy saved, corpus (0.1..3.0 p) per med run @1.0p
|
||||
3 7.64% 23086 34 299 1104 25822 .. 493853 2.83
|
||||
5 9.58% 23739 53 330 1104 32351 .. 618714 4.42
|
||||
10 14.52% 25211 89 433 1141 49044 .. 937973 7.42
|
||||
20 24.74% 27799 139 621 2059 83590 .. 1598649 11.58
|
||||
energy saved over the WHOLE corpus, heat-limited: the 0.1..3.0 power
|
||||
bracket, then the p=1.0 column = what one median suppressed RUN is worth
|
||||
(1.0 power is the mode of the measured landed-hit histogram).
|
||||
Median run lengths 34/53/89/139 ticks; one 1.0-power landed hit = 3.0.
|
||||
|
||||
CAVEAT, measured: the recorded energy ledger closes EXACTLY on
|
||||
start + landed-gains - damage = end (residual -0.00 over 35065 rounds),
|
||||
i.e. these captures DO NOT charge the firepower cost. The cost column
|
||||
is therefore computed from the game rules, not read off the data.
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
"""j160 open-loop energy measurement for the FIRING FLOOR / EXHAUSTION RAM.
|
||||
|
||||
NO battle, NO server, NO counterfactual replay. This reads the ALREADY RECORDED
|
||||
closed-loop captures under /tmp and reports, per tick:
|
||||
|
||||
* how often SELF energy sits below a floor candidate,
|
||||
* whether the owner's "both low, nobody firing" situation actually occurs,
|
||||
* who crosses a low-energy line FIRST (self or the enemy),
|
||||
* how often the enemy is low while we are healthy -- the opportunity the
|
||||
exhaustion trigger (TR_RAM_ENEMY_ENERGY) would act on.
|
||||
|
||||
Deliberately produces NO "damage if we had not fired" number: the offline
|
||||
harness scored 0/6 on closed-loop questions (docs/offline_harness_trust.md),
|
||||
so that class of number is worthless here.
|
||||
|
||||
Usage: python3 common_libs/tests/measure_ramfloor_energy [glob-dir]
|
||||
"""
|
||||
import json, os, glob, statistics, sys, array
|
||||
|
||||
ROOTS = sys.argv[1:] or ["/tmp"]
|
||||
|
||||
def recordings():
|
||||
out = []
|
||||
for root in ROOTS:
|
||||
for f in glob.glob(os.path.join(root, "**", "*.jsonl"), recursive=True):
|
||||
if f.endswith(".events.jsonl"): continue
|
||||
try:
|
||||
with open(f) as fh: first = fh.readline()
|
||||
except OSError: continue
|
||||
if '"closed_loop":true' not in first.replace(" ", ""): continue
|
||||
out.append(f)
|
||||
return sorted(out)
|
||||
|
||||
def split_rounds(path):
|
||||
"""Yield per-round [(self, enemy)] from a recording, using its round map."""
|
||||
rf = path.replace(".jsonl", ".jsonl.rounds.json")
|
||||
bounds = []
|
||||
if os.path.exists(rf):
|
||||
try:
|
||||
for r in json.load(open(rf))["rounds"]:
|
||||
bounds.append((r["startTick"], r["startTick"] + r["count"]))
|
||||
except Exception: bounds = []
|
||||
rows = []
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
if '"tick"' not in line: continue
|
||||
try: d = json.loads(line)
|
||||
except ValueError: continue
|
||||
if "se" in d and "ee" in d: rows.append((d["tick"], d["se"], d["ee"]))
|
||||
if not rows: return []
|
||||
if not bounds: bounds = [(rows[0][0], rows[-1][0] + 1)]
|
||||
rounds = []
|
||||
for s, e in bounds:
|
||||
r = [(se, ee) for t, se, ee in rows if s <= t < e]
|
||||
if not r: continue
|
||||
# trim the trailing both-disabled tail: a dead bot sits at ~0 forever
|
||||
last = max(i for i, (a, b) in enumerate(r) if a > 0 and b > 0)
|
||||
rounds.append(r[:last + 1])
|
||||
return rounds
|
||||
|
||||
def main():
|
||||
files = recordings()
|
||||
rounds = []
|
||||
for f in files: rounds += split_rounds(f)
|
||||
if not rounds:
|
||||
print("no closed-loop recordings found"); return
|
||||
N = sum(len(r) for r in rounds)
|
||||
se, ee = array.array("d"), array.array("d")
|
||||
for r in rounds:
|
||||
for a, b in r: se.append(a); ee.append(b)
|
||||
print(f"recordings={len(files)} rounds={len(rounds)} ticks={N}\n")
|
||||
|
||||
THR = [5, 10, 15, 20, 25]
|
||||
print("=== A) SELF energy below a floor candidate (share of ticks) ===")
|
||||
print(f"{'floor':>5} {'pct':>7} {'rounds hit':>10} {'med run':>8} {'p90 run':>8} {'max run':>8}")
|
||||
for t in THR:
|
||||
tot, hit, lens = 0, 0, []
|
||||
for r in rounds:
|
||||
cur, got = 0, False
|
||||
for a, _ in r:
|
||||
if a <= t: cur += 1; tot += 1; got = True
|
||||
elif cur: lens.append(cur); cur = 0
|
||||
if cur: lens.append(cur)
|
||||
hit += 1 if got else 0
|
||||
lens.sort()
|
||||
print(f"{t:>5} {100*tot/N:>6.2f}% {hit:>10} "
|
||||
f"{statistics.median(lens) if lens else 0:>8.0f} "
|
||||
f"{lens[int(.9*len(lens))] if lens else 0:>8} "
|
||||
f"{lens[-1] if lens else 0:>8}")
|
||||
|
||||
print("\n=== B) the owner's \"both low, nobody firing\" situation ===")
|
||||
for t in THR:
|
||||
both = sum(1 for a, b in zip(se, ee) if a <= t and b <= t)
|
||||
sonly = sum(1 for a, b in zip(se, ee) if a <= t < b)
|
||||
eonly = sum(1 for a, b in zip(se, ee) if b <= t < a)
|
||||
print(f" both<={t:>2}: {100*both/N:6.3f}% self-only {100*sonly/N:6.2f}%"
|
||||
f" enemy-only {100*eonly/N:6.2f}%")
|
||||
|
||||
print("\n=== C) who crosses a low-energy line FIRST (per round) ===")
|
||||
for t in [10, 15, 20, 25]:
|
||||
s = e = n = 0
|
||||
for r in rounds:
|
||||
fs = next((i for i, x in enumerate(r) if x[0] <= t), None)
|
||||
fe = next((i for i, x in enumerate(r) if x[1] <= t), None)
|
||||
if fs is None and fe is None: n += 1
|
||||
elif fs is None or (fe is not None and fs < fe): s += 1
|
||||
else: e += 1
|
||||
m = len(rounds)
|
||||
print(f" t={t:>2}: self-first {s:>5} ({100*s/m:5.1f}%) "
|
||||
f"enemy-first {e:>5} ({100*e/m:5.1f}%) neither {n:>4} ({100*n/m:4.1f}%)")
|
||||
|
||||
print("\n=== D) \"the enemy can no longer fire\" (server rejects energy <= power) ===")
|
||||
for p in (0.4, 1.0, 1.95, 3.0):
|
||||
c = sum(1 for b in ee if b <= p)
|
||||
c2 = sum(1 for a, b in zip(se, ee) if b <= p and a > 20)
|
||||
print(f" enemy <= {p:>4}: {100*c/N:6.3f}% and self>20: {100*c2/N:6.3f}%")
|
||||
|
||||
print("\n=== E) exhaustion-trigger OPPORTUNITY: enemy low while we are healthy ===")
|
||||
for t in [10, 20, 30]:
|
||||
row = " ".join(f"self>{fl}: {100*sum(1 for a,b in zip(se,ee) if b<=t and a>fl)/N:6.2f}%"
|
||||
for fl in (0, 20, 25))
|
||||
print(f" enemy<={t:>2} {row}")
|
||||
|
||||
print("\n=== F) the ALREADY-SHIPPED finisher (enemy<20 & self>enemy & dist<300) ===")
|
||||
c = 0
|
||||
# dist needs the raw file; recompute over the whole trimmed corpus
|
||||
for f in files:
|
||||
with open(f) as fh:
|
||||
for line in fh:
|
||||
if '"se"' not in line: continue
|
||||
try: d = json.loads(line)
|
||||
except ValueError: continue
|
||||
if "se" not in d: continue
|
||||
if d["ee"] < 20 and d["se"] > d["ee"] and \
|
||||
((d["ex"]-d["sx"])**2 + (d["ey"]-d["sy"])**2) ** .5 < 300:
|
||||
c += 1
|
||||
print(f" {c} ticks ({100*c/N:.4f}% of the trimmed corpus)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,244 @@
|
||||
## OFFLINE — j153. THE OWNER'S "HOLD WHEN TRAPPED" — OPEN-LOOP DESCRIPTORS ONLY.
|
||||
##
|
||||
## The claim: "if no tile is found to go, to not choose the less dangerous, but
|
||||
## to stay still! the next tick probably the situation already changed and we
|
||||
## did not commit to any dangerous place."
|
||||
##
|
||||
## This ruler reports FOUR DESCRIPTORS of the RECORDED field around every forced
|
||||
## pick (the pick the mover makes when the safe set is too small to draw from).
|
||||
## They are all measured on the recorded trajectory, forward-looking in TIME but
|
||||
## NOT counterfactual: nothing here replays "what damage would holding have
|
||||
## cost". Per `docs/offline_harness_trust.md` that question is closed-loop and
|
||||
## the replay ruler scored 0/6 on such questions, so it is NOT asked here.
|
||||
##
|
||||
## 1. WAIT WINDOW — at a forced pick, holding OUR POSITION fixed, how many
|
||||
## ticks pass before ANY tile inside the reachable hull is safe
|
||||
## (pathMaxHeat <= PathDangerThreshold, after the CoolestLevels=2 filter)?
|
||||
## CAVEAT: the safe set depends on where we are, so this holds the pick
|
||||
## position and replays the field — the recorded field still belongs to a
|
||||
## bot that moved, so the window is an APPROXIMATION of the wait a holder
|
||||
## would really see. It is an UPPER bound on the wait (a moving recorded
|
||||
## bot stirs the field) and it is reported with the censoring share.
|
||||
## 2. FRESH FIRE — ticks since the enemy's last confirmed shot (energy-drop
|
||||
## detection, the same signal the mover itself uses).
|
||||
## 3. OUR OWN TILE — is our current tile already over the threshold at the
|
||||
## forced pick? (if yes, "stay" is not on the table at all)
|
||||
## 4. DISTANCE — all of the above split by distance to the nearest enemy.
|
||||
##
|
||||
## No battle, no Java, no server, no behaviour change (the knob is off).
|
||||
##
|
||||
## Run:
|
||||
## nim c -r --path:common_libs --nimcache:/tmp/nc_j153 \
|
||||
## common_libs/tests/measure_tfil_hold_window.nim [fixture.jsonl ...]
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, random, sequtils, sets]
|
||||
import std/strutils except fromHex
|
||||
import gun_harness/offline_range
|
||||
include movements/the_floor_is_lava
|
||||
|
||||
const PathSampleStep = 18.0 ## the picker's own path sampling step
|
||||
const MaxWait = 40 ## censor horizon, ticks
|
||||
const ArenaW = 800.0
|
||||
const ArenaH = 600.0
|
||||
|
||||
type Probe = object
|
||||
idx: int ## fixture index of the forced pick
|
||||
x, y, heading, speed: float ## OUR position at the pick (held fixed)
|
||||
dist: float ## to the nearest enemy at the pick
|
||||
ownHot: bool ## our own tile already over the threshold
|
||||
emptySet: bool ## the safe set was EMPTY (0), not merely < 2
|
||||
sinceFire: int ## ticks since the enemy's last confirmed shot
|
||||
wait: int ## filled below: -1 = censored
|
||||
resolved: bool
|
||||
|
||||
proc loadRoundStarts(path: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
for side in [path & ".rounds.json",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(path) & ".rounds.json")]:
|
||||
if not fileExists(side): continue
|
||||
let root = parseFile(side)
|
||||
if not root.hasKey("rounds"): continue
|
||||
for r in root["rounds"]:
|
||||
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
||||
|
||||
proc pathMax(m: TFILModule, fx, fy, tx, ty: float): float =
|
||||
let ddx = tx - fx; let ddy = ty - fy
|
||||
let d = sqrt(ddx*ddx + ddy*ddy)
|
||||
if d <= 0.1: return 0.0
|
||||
let steps = max(1, int(d / PathSampleStep))
|
||||
var h = 0.0
|
||||
for si in 0..steps:
|
||||
let f = si.float / steps.float
|
||||
let (sc, sr) = m.tileAt(fx + ddx * f, fy + ddy * f)
|
||||
h = max(h, m.lavaAt(sc, sr))
|
||||
h
|
||||
|
||||
proc coolestOf(m: TFILModule, tiles: seq[tuple[col, row: int]]):
|
||||
seq[tuple[col, row: int]] =
|
||||
## The picker's CoolestLevels=2 filter: only the coolest 2 DISTINCT lava
|
||||
## values among the tiles count as candidates at all.
|
||||
var vals: seq[float]
|
||||
for t in tiles:
|
||||
let v = m.lavaAt(t.col, t.row)
|
||||
var found = false
|
||||
for dv in vals:
|
||||
if dv == v: found = true; break
|
||||
if not found: vals.add v
|
||||
for i in 1..<vals.len:
|
||||
let key = vals[i]; var j = i - 1
|
||||
while j >= 0 and vals[j] > key:
|
||||
vals[j + 1] = vals[j]; dec j
|
||||
vals[j + 1] = key
|
||||
let lv = min(CoolestLevels, vals.len)
|
||||
for t in tiles:
|
||||
let v = m.lavaAt(t.col, t.row)
|
||||
for li in 0..<lv:
|
||||
if v == vals[li]:
|
||||
result.add t
|
||||
break
|
||||
|
||||
proc safeCount(m: TFILModule, tiles: seq[tuple[col, row: int]],
|
||||
fx, fy, thr: float): int =
|
||||
for t in coolestOf(m, tiles):
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
if pathMax(m, fx, fy, tx, ty) <= thr: inc result
|
||||
|
||||
proc safeTileExists(m: TFILModule, p: Probe, thr: float): bool =
|
||||
## The picker's own definition, from OUR HELD POSITION: inside the reachable
|
||||
## hull -> CoolestLevels=2 coolest distinct lava values -> path heat filter.
|
||||
let hull = computeReachableHull(p.x, p.y, p.heading, p.speed,
|
||||
ArenaW, ArenaH, HullTicks)
|
||||
if hull.len < 3: return false
|
||||
var inside: seq[tuple[col, row: int]]
|
||||
for row in 0..<m.rows:
|
||||
for col in 0..<m.cols:
|
||||
let cx = m.marginX + (col.float + 0.5) * GridSize
|
||||
let cy = m.marginY + (row.float + 0.5) * GridSize
|
||||
if pointInHull(cx, cy, hull): inside.add (col, row)
|
||||
safeCount(m, inside, p.x, p.y, thr) > 0
|
||||
|
||||
proc nearestEnemyDist(ws: WorldState): float =
|
||||
result = Inf
|
||||
for e in ws.enemies:
|
||||
result = min(result, sqrt((e.x - ws.selfX)^2 + (e.y - ws.selfY)^2))
|
||||
|
||||
proc collect(fx: seq[WorldState], starts: HashSet[int], seed: int): seq[Probe] =
|
||||
randomize(seed)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
var lastBullets = 0
|
||||
var sinceFire = 99
|
||||
for i in 0..<fx.len:
|
||||
if i == 0 or i in starts: m.resetRound()
|
||||
discard m.computeMove(fx[i])
|
||||
if m.bullets.len > lastBullets: sinceFire = 0 else: inc sinceFire
|
||||
lastBullets = m.bullets.len
|
||||
if m.picks == lastPicks: continue
|
||||
lastPicks = m.picks
|
||||
# a pick happened: was the safe set big enough to draw from?
|
||||
let safePre = safeCount(m, m.cachedInsideTiles, fx[i].selfX, fx[i].selfY,
|
||||
TfilDangerThreshold)
|
||||
if safePre >= 2: continue # not a forced pick
|
||||
let (cc, cr) = m.tileAt(fx[i].selfX, fx[i].selfY)
|
||||
result.add Probe(idx: i, x: fx[i].selfX, y: fx[i].selfY,
|
||||
heading: fx[i].selfHeading, speed: fx[i].selfSpeed,
|
||||
dist: nearestEnemyDist(fx[i]),
|
||||
emptySet: safePre == 0,
|
||||
ownHot: m.lavaAt(cc, cr) > TfilDangerThreshold,
|
||||
sinceFire: sinceFire, wait: -1)
|
||||
|
||||
proc fillWaits(fx: seq[WorldState], starts: HashSet[int], seed: int,
|
||||
probes: var seq[Probe]) =
|
||||
## Second linear pass, SAME trajectory: at tick j every still-open probe is
|
||||
## advanced to k = j - idx and asked whether a safe tile exists at k.
|
||||
randomize(seed)
|
||||
var m = initTFIL()
|
||||
for j in 0..<fx.len:
|
||||
if j == 0 or j in starts: m.resetRound()
|
||||
discard m.computeMove(fx[j])
|
||||
for p in probes.mitems:
|
||||
if p.resolved: continue
|
||||
let k = j - p.idx
|
||||
if k < 1: continue
|
||||
if k > MaxWait:
|
||||
p.resolved = true; p.wait = -1; continue
|
||||
if safeTileExists(m, p, TfilDangerThreshold):
|
||||
p.resolved = true; p.wait = k
|
||||
|
||||
proc mean(xs: seq[int]): float =
|
||||
if xs.len == 0: return 0.0
|
||||
var t = 0
|
||||
for x in xs: t += x
|
||||
t.float / xs.len.float
|
||||
|
||||
proc median(xs: seq[int]): int =
|
||||
if xs.len == 0: return -1
|
||||
let s = xs.sorted()
|
||||
s[s.len div 2]
|
||||
|
||||
proc shareN(n, d: int): string =
|
||||
if d == 0: return "n/a"
|
||||
&"{100.0 * n.float / d.float:.1f}%"
|
||||
|
||||
proc share(n, d: int): string = shareN(n, d)
|
||||
|
||||
proc report(label: string, ps: seq[Probe]) =
|
||||
echo &"\n\u2550\u2550\u2550 {label}"
|
||||
if ps.len == 0: echo " no forced picks"; return
|
||||
let waits = ps.filterIt(it.wait >= 0).mapIt(it.wait)
|
||||
let cens = ps.filterIt(it.wait < 0)
|
||||
echo &" forced picks: {ps.len} of which EMPTY safe set (0 tiles): " &
|
||||
&"{shareN(ps.filterIt(it.emptySet).len, ps.len)}"
|
||||
echo &" WAIT WINDOW (ticks until ANY safe tile exists, position held):"
|
||||
echo &" median {median(waits)} mean {mean(waits):.1f}" &
|
||||
&" resolved {waits.len}/{ps.len} censored(>{MaxWait}) {cens.len}"
|
||||
for k in [1, 3, 5, 10]:
|
||||
echo &" within {k:>2} tick(s): {share(waits.filterIt(it <= k).len, ps.len)}"
|
||||
echo &" FRESH FIRE (ticks since the enemy's last confirmed shot):"
|
||||
echo &" same tick {shareN(ps.filterIt(it.sinceFire == 0).len, ps.len)}" &
|
||||
&" prev 1 tick {shareN(ps.filterIt(it.sinceFire <= 1).len, ps.len)}" &
|
||||
&" prev 3 ticks {shareN(ps.filterIt(it.sinceFire <= 3).len, ps.len)}" &
|
||||
&" >3 ticks {shareN(ps.filterIt(it.sinceFire > 3).len, ps.len)}"
|
||||
echo &" OUR OWN TILE already over the threshold: " &
|
||||
&"{shareN(ps.filterIt(it.ownHot).len, ps.len)}"
|
||||
for (name, lo, hi) in [("close <150px", 0.0, 150.0), ("mid 150-300px", 150.0, 300.0),
|
||||
("far >300px", 300.0, 1.0e9)]:
|
||||
let g = ps.filterIt(it.dist >= lo and it.dist < hi)
|
||||
if g.len == 0:
|
||||
echo &" {name}: n/a"; continue
|
||||
let w = g.filterIt(it.wait >= 0).mapIt(it.wait)
|
||||
echo &" {name}: n={g.len} median wait {median(w)} " &
|
||||
&"<=3t {share(w.filterIt(it <= 3).len, g.len)} <=10t {share(w.filterIt(it <= 10).len, g.len)}" &
|
||||
&" fired<=1t ago {shareN(g.filterIt(it.sinceFire <= 1).len, g.len)}" &
|
||||
&" own tile hot {shareN(g.filterIt(it.ownHot).len, g.len)}"
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
let args = commandLineParams()
|
||||
let fixtures = if args.len > 0: args
|
||||
else: @["/tmp/firelag_live2/tfil_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/tfil_off/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_off/run1.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
|
||||
var total: seq[Probe]
|
||||
for f in fixtures:
|
||||
if not fileExists(f):
|
||||
echo "skip (missing): ", f; continue
|
||||
let fx = loadFixture(f).states
|
||||
let starts = loadRoundStarts(f)
|
||||
for seed in [7, 8, 9]:
|
||||
var ps = collect(fx, starts, seed)
|
||||
fillWaits(fx, starts, seed, ps)
|
||||
total.add ps
|
||||
if seed == 7: report(extractFilename(f), ps)
|
||||
report("ALL FIXTURES x 3 SEEDS", total)
|
||||
@@ -0,0 +1,291 @@
|
||||
## OFFLINE — j151. THE OWNER'S TWO PAINTERS, PER PICK.
|
||||
##
|
||||
## "the bot choose a tile that is almost perpendicular to it, a tile that is not
|
||||
## reachable in feasible time and 1 will put the bot in danger trying to go
|
||||
## there 2 will not arrive there as a new location will drive it away."
|
||||
##
|
||||
## This ruler replays recorded fixtures through the REAL
|
||||
## `TFILModule.computeMove` and records, for EVERY pick:
|
||||
## turn° angle between the current heading and the chosen tile
|
||||
## pathMax/Mean lava on the straight-line path bot -> chosen tile
|
||||
## destHeat lava on the chosen tile itself
|
||||
## tta dist / MaxSpeed, i.e. ticks to arrive at full speed
|
||||
## promoted the pick had to break the heat filter (safePre < 2)
|
||||
## safePre size of the safe set BEFORE the "keep 2" promotion
|
||||
## hotAtTta the destination tile was OVER the threshold `tta` ticks
|
||||
## later, on the recorded (true) future <- the feasibility test
|
||||
## tile the chosen (col,row) <- j152: pick DIVERSITY
|
||||
## j152: every row of the sweep table also reports the DIVERSITY cost (distinct
|
||||
## tiles, entropy, top-tile share). j51 measured the randomness in this draw as
|
||||
## LOAD-BEARING, so a geometry weight that improves the geometry numbers while
|
||||
## collapsing the distribution is a regression, not a win.
|
||||
## No battle, no Java, no server, no behaviour change.
|
||||
##
|
||||
## Run:
|
||||
## nim c -r --path:common_libs --nimcache:/tmp/nc_j151 \
|
||||
## common_libs/tests/measure_tfil_pick_defects.nim [fixture.jsonl ...]
|
||||
## Env it forwards: TR_TFIL_CORRIDOR_TICKS, TR_TFIL_DANGER_THRESHOLD, ...
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, sets, random, sequtils, tables]
|
||||
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||
import gun_harness/offline_range
|
||||
# Private-field access: include (do NOT import) the shipped mover.
|
||||
include movements/the_floor_is_lava
|
||||
|
||||
const PathSampleStep = 18.0 # the picker's own sampling step
|
||||
const PerpDeg = 60.0 ## the owner's "perpendicular"
|
||||
|
||||
type Pick = object
|
||||
turn, pathMax, pathMean, destHeat, dist, tta: float
|
||||
promoted: bool
|
||||
safePre, cand: int
|
||||
hotAtTta: bool ## destination over threshold when we would arrive
|
||||
reached: bool ## we actually got within ArriveRadius by then
|
||||
col, row: int ## the chosen tile (diversity)
|
||||
|
||||
proc loadRoundStarts(path: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
for side in [path & ".rounds.json",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(path) & ".rounds.json")]:
|
||||
if not fileExists(side): continue
|
||||
let root = parseFile(side)
|
||||
if not root.hasKey("rounds"): continue
|
||||
for r in root["rounds"]:
|
||||
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
||||
|
||||
proc pathHeat(m: TFILModule, fx, fy, tx, ty: float): tuple[max, mean: float] =
|
||||
let ddx = tx - fx
|
||||
let ddy = ty - fy
|
||||
let lineDist = sqrt(ddx*ddx + ddy*ddy)
|
||||
if lineDist <= 0.1: return (0.0, 0.0)
|
||||
let steps = max(1, int(lineDist / PathSampleStep))
|
||||
var h = 0.0
|
||||
var s = 0.0
|
||||
for si in 0..steps:
|
||||
let frac = si.float / steps.float
|
||||
let (sc, sr) = m.tileAt(fx + ddx * frac, fy + ddy * frac)
|
||||
let v = m.lavaAt(sc, sr)
|
||||
h = max(h, v)
|
||||
s += v
|
||||
(h, s / (steps + 1).float)
|
||||
|
||||
proc safeSetSize(m: TFILModule, thr: float): int =
|
||||
## Replay of the picker's own hard filter over the tiles it considered, from
|
||||
## the same lava snapshot the pick saw. No re-implementation of the choice.
|
||||
for t in m.cachedInsideTiles:
|
||||
let tx = m.marginX + (t.col.float + 0.5) * GridSize
|
||||
let ty = m.marginY + (t.row.float + 0.5) * GridSize
|
||||
if pathHeat(m, m.lastBotX, m.lastBotY, tx, ty).max <= thr: inc result
|
||||
|
||||
proc replay(path: string, seed: int): seq[Pick] =
|
||||
randomize(seed)
|
||||
loadTfilCommitEnv()
|
||||
let fx = loadFixture(path)
|
||||
let starts = loadRoundStarts(path)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
var pending: seq[tuple[col, row: int; at: int; idx: int]]
|
||||
var mActive = 0
|
||||
for si in 0..<fx.states.len:
|
||||
if si == 0 or si in starts: m.resetRound()
|
||||
discard m.computeMove(fx.states[si])
|
||||
inc mActive
|
||||
# 1. a new pick happened this tick -> record the geometry
|
||||
if m.picks != lastPicks:
|
||||
lastPicks = m.picks
|
||||
let (cc, cr) = m.tileAt(m.commitTarget.x, m.commitTarget.y)
|
||||
let ang = arctan2(m.commitTarget.y - m.lastBotY, m.commitTarget.x - m.lastBotX) *
|
||||
180.0 / PI - fx.states[si].selfHeading
|
||||
var turn = abs(((ang + 180.0) mod 360.0) - 180.0)
|
||||
if turn > 180.0: turn = 360.0 - turn
|
||||
let d = sqrt((m.commitTarget.x - m.lastBotX)^2 + (m.commitTarget.y - m.lastBotY)^2)
|
||||
let (pmx, pmean) = pathHeat(m, m.lastBotX, m.lastBotY, m.commitTarget.x, m.commitTarget.y)
|
||||
result.add Pick(turn: turn, pathMax: pmx, pathMean: pmean,
|
||||
destHeat: m.lavaAt(cc, cr), dist: d, tta: d / MaxSpeed,
|
||||
promoted: m.lastPickPromoted,
|
||||
safePre: safeSetSize(m, TfilDangerThreshold),
|
||||
cand: m.lastPickSafe, hotAtTta: false, reached: false,
|
||||
col: cc, row: cr)
|
||||
pending.add (col: cc, row: cr, at: mActive + int(d / MaxSpeed), idx: result.high)
|
||||
# 2. the arrival probe on the recorded true future
|
||||
var keep: seq[tuple[col, row: int; at: int; idx: int]]
|
||||
for p in pending:
|
||||
if mActive < p.at:
|
||||
keep.add p
|
||||
else:
|
||||
result[p.idx].hotAtTta = m.lavaAt(p.col, p.row) > TfilDangerThreshold
|
||||
let px = m.marginX + (p.col.float + 0.5) * GridSize
|
||||
let py = m.marginY + (p.row.float + 0.5) * GridSize
|
||||
result[p.idx].reached = sqrt((m.lastBotX - px)^2 + (m.lastBotY - py)^2) < ArriveRadius
|
||||
pending = keep
|
||||
|
||||
proc mean(x: seq[float]): float =
|
||||
if x.len == 0: return 0.0
|
||||
var s = 0.0
|
||||
for v in x: s += v
|
||||
s / x.len.float
|
||||
|
||||
proc pc(x: float): string = &"{100.0 * x:.1f}%"
|
||||
proc f1(x: float): string = &"{x:.1f}"
|
||||
proc f2(x: float): string = &"{x:.2f}"
|
||||
|
||||
proc report(label, path: string, picks: seq[Pick]) =
|
||||
echo &"\n\u2550\u2550\u2550 {label} {extractFilename(path)}"
|
||||
if picks.len == 0: echo " no picks"; return
|
||||
let thr = TfilDangerThreshold
|
||||
var groups = [("EMPTY safe set (promoted)", picks.filterIt(it.promoted)),
|
||||
("non-empty safe set", picks.filterIt(not it.promoted))]
|
||||
var allPerp, allHot, allFar, allBad = 0
|
||||
for (name, g) in groups:
|
||||
let perp = g.filterIt(it.turn > PerpDeg)
|
||||
let hot = g.filterIt(it.pathMax > thr) # crosses a hot region
|
||||
let far = g.filterIt(it.tta > CommitTicks.float) # cannot arrive in the commitment
|
||||
let bad = g.filterIt(it.turn > PerpDeg and it.pathMax > thr)
|
||||
let badFar = g.filterIt(it.turn > PerpDeg and it.tta > CommitTicks.float)
|
||||
let futHot = g.filterIt(it.hotAtTta)
|
||||
let reach = g.filterIt(it.reached)
|
||||
echo &" {name}: {g.len} picks ({pc(g.len.float/picks.len.float)} of all)"
|
||||
if g.len == 0: continue
|
||||
echo &" PERPENDICULAR (>60\u00b0) {perp.len:>6} {pc(perp.len.float/g.len.float):>7}" &
|
||||
&" mean pathMax {f1(mean(perp.mapIt(it.pathMax)))}"
|
||||
echo &" path crosses HOT {hot.len:>6} {pc(hot.len.float/g.len.float):>7}" &
|
||||
&" mean pathMax(all) {f1(mean(g.mapIt(it.pathMax)))} destHeat {f1(mean(g.mapIt(it.destHeat)))}"
|
||||
echo &" tta > commit({CommitTicks}) {far.len:>6} {pc(far.len.float/g.len.float):>7}" &
|
||||
&" mean tta {f1(mean(g.mapIt(it.tta)))} max {f1(g.mapIt(it.tta).max)}"
|
||||
echo &" PERP + hot {bad.len:>6} | PERP + far {badFar.len:>6}"
|
||||
echo &" dest HOT when we arrive {futHot.len:>6} {pc(futHot.len.float/g.len.float):>7}" &
|
||||
&" | actually arrived {pc(reach.len.float/g.len.float):>7}"
|
||||
echo &" mean turn {f1(mean(g.mapIt(it.turn)))}\u00b0 mean safePre {f1(mean(g.mapIt(it.safePre.float)))}" &
|
||||
&" mean cand {f1(mean(g.mapIt(it.cand.float)))}"
|
||||
allPerp += perp.len; allHot += hot.len; allFar += far.len
|
||||
allBad += bad.len + badFar.len
|
||||
echo &" ALL: perp {pc(allPerp.float/picks.len.float)} hot-path {pc(allHot.float/picks.len.float)}" &
|
||||
&" far {pc(allFar.float/picks.len.float)} (perp&(hot|far)) {pc(allBad.float/picks.len.float)}"
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
let args = commandLineParams()
|
||||
let detail = "--detail" in args
|
||||
let fixtures: seq[string] =
|
||||
block:
|
||||
if detail:
|
||||
var v: seq[string]
|
||||
for a in args:
|
||||
if not a.startsWith("--"): v.add a
|
||||
v
|
||||
else:
|
||||
@["/tmp/firelag_live2/tfil_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/tfil_off/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_on/run1.jsonl",
|
||||
"/tmp/firelag_live2/strafe_off/run1.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_corners.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_crazy.jsonl",
|
||||
currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_spinbot.jsonl"]
|
||||
|
||||
# ── j152: the sweep, with the DIVERSITY cost on every row ────────────────────
|
||||
## (label, TR_TFIL_GEO_MODE, TR_TFIL_GEO_TAU, TR_TFIL_ARRIVE_TICKS)
|
||||
type Arm = tuple[label, mode, tau, arrive: string]
|
||||
|
||||
proc diversity(picks: seq[Pick]): tuple[distinctN, topShare, entBits, normEnt: float] =
|
||||
## Shannon entropy (bits) of the CHOICE distribution over tiles. `normEnt` is
|
||||
## H / log2(distinct): 1.0 = the arm spreads its picks over exactly as many
|
||||
## tiles as the baseline, 0.0 = every pick is the same tile.
|
||||
var counts: Table[(int, int), int]
|
||||
for p in picks: counts[(p.col, p.row)] = counts.getOrDefault((p.col, p.row)) + 1
|
||||
result.distinctN = counts.len.float
|
||||
if picks.len == 0: return
|
||||
var h = 0.0
|
||||
var top = 0
|
||||
for _, n in counts.pairs:
|
||||
let q = n.float / picks.len.float
|
||||
h -= q * log2(q)
|
||||
top = max(top, n)
|
||||
result.topShare = top.float / picks.len.float
|
||||
result.entBits = h
|
||||
result.normEnt = if result.distinctN > 1.0: h / log2(result.distinctN) else: 0.0
|
||||
|
||||
proc pctS(x, n: int): string =
|
||||
if n == 0: return " n/a"
|
||||
pc(x.float / n.float)
|
||||
proc isPerp(p: Pick): bool = p.turn > PerpDeg
|
||||
proc isPromoted(p: Pick): bool = p.promoted
|
||||
proc isFar(p: Pick): bool = p.tta > 15.0
|
||||
proc isReached(p: Pick): bool = p.reached
|
||||
proc isHotAtTta(p: Pick): bool = p.hotAtTta
|
||||
|
||||
proc row(label: string, picks: seq[Pick]): string =
|
||||
let n = picks.len
|
||||
let emp = picks.filterIt(isPromoted(it))
|
||||
let nes = picks.filterIt(not isPromoted(it))
|
||||
let perp = picks.filterIt(isPerp(it)).len
|
||||
let perpE = emp.filterIt(isPerp(it)).len
|
||||
let perpN = nes.filterIt(isPerp(it)).len
|
||||
let far = picks.filterIt(isFar(it)).len
|
||||
let reach = picks.filterIt(isReached(it)).len
|
||||
let hot = picks.filterIt(isHotAtTta(it)).len
|
||||
let d = diversity(picks)
|
||||
&"{label:<22} {pctS(perp, n):>7} {pctS(perpE, emp.len):>7} {pctS(perpN, nes.len):>7}" &
|
||||
&" {pctS(far, n):>7} {f1(mean(picks.mapIt(it.tta))):>6}" &
|
||||
&" {pctS(reach, n):>7} {pctS(hot, n):>7} {pctS(emp.len, n):>7}" &
|
||||
&" {d.distinctN.int:>6} {f2(d.entBits):>6} {f2(d.normEnt):>6} {pc(d.topShare):>7}" &
|
||||
&" {f1(mean(picks.mapIt(it.cand.float))):>5}"
|
||||
|
||||
proc header(): string =
|
||||
result = "arm".align(22, ' ')
|
||||
for (h, w) in [("perp", 7), ("perpE", 7), ("perpN", 7), ("far", 7), ("mtta", 6),
|
||||
("REACH", 7), ("hotArr", 7), ("empty", 7), ("tiles", 6),
|
||||
("Hbits", 6), ("H/", 6), ("top1", 7), ("cand", 5)]:
|
||||
result &= " " & h.align(w, ' ')
|
||||
|
||||
# `perpE`/`perpN` = the perpendicular rate in the FORCED (empty safe set) and the
|
||||
# non-empty populations; `REACH` = the headline (tile actually stood on at tta);
|
||||
# `tiles`/`Hbits`/`H/`/`top1` = the diversity cost; `cand` = mean draw-set size.
|
||||
let arms: seq[Arm] = @[
|
||||
("BASELINE (off)", "off", "0", "0"),
|
||||
("turn-soft tau90", "turn-soft", "90", "0"),
|
||||
("turn-soft tau45", "turn-soft", "45", "0"),
|
||||
("turn-soft tau20", "turn-soft", "20", "0"),
|
||||
("turn-topk", "turn-topk", "45", "0"),
|
||||
("turn-rej tau60", "turn-rej", "60", "0"),
|
||||
("dist-soft tau90", "dist-soft", "90", "0"),
|
||||
("dist-soft tau30", "dist-soft", "30", "0"),
|
||||
("both-soft tau90", "both-soft", "90", "0"),
|
||||
("both-soft tau45", "both-soft", "45", "0"),
|
||||
("both-soft tau20", "both-soft", "20", "0"),
|
||||
("both-topk", "both-topk", "45", "0"),
|
||||
("both-rej tau60", "both-rej", "60", "0"),
|
||||
# j151 interaction: a soft distance preference vs the HARD arrival bound.
|
||||
("arrive15 (j151)", "off", "0", "15"),
|
||||
("arrive15+both t45", "both-soft", "45", "15")]
|
||||
|
||||
echo "\n", header()
|
||||
for a in arms:
|
||||
putEnv("TR_TFIL_GEO_MODE", a.mode)
|
||||
putEnv("TR_TFIL_GEO_TAU", a.tau)
|
||||
putEnv("TR_TFIL_ARRIVE_TICKS", a.arrive)
|
||||
var picks: seq[Pick]
|
||||
for f in fixtures:
|
||||
if not fileExists(f): continue
|
||||
for seed in [7, 8, 9]: picks.add replay(f, seed)
|
||||
echo row(a.label, picks)
|
||||
|
||||
# ── per-fixture detail (--detail only), for the BASELINE arm ────────────────
|
||||
if detail:
|
||||
putEnv("TR_TFIL_GEO_MODE", arms[0].mode)
|
||||
putEnv("TR_TFIL_GEO_TAU", arms[0].tau)
|
||||
putEnv("TR_TFIL_ARRIVE_TICKS", arms[0].arrive)
|
||||
var total: seq[Pick]
|
||||
for f in fixtures:
|
||||
if not fileExists(f):
|
||||
echo "skip (missing): ", f; continue
|
||||
for seed in [7, 8, 9]:
|
||||
let p = replay(f, seed)
|
||||
total.add p
|
||||
if seed == 7: report("seed 7", f, p)
|
||||
report("ALL FIXTURES x 3 SEEDS", "", total)
|
||||
@@ -0,0 +1,118 @@
|
||||
## OFFLINE — j150. WHERE DOES THE LAVA PICKER LOSE ITS TILES?
|
||||
##
|
||||
## The owner's report: "the bot chooses only between a poor number of tiles while
|
||||
## there are a lot of them available but not considered". This ruler drives the
|
||||
## REAL `TFILModule.computeMove` (with `TR_TFIL_DIAG=1`) over recorded
|
||||
## DrussGT fixtures and reads the per-pick loss histogram the mover counts:
|
||||
##
|
||||
## reachable hull -> CoolestLevels(=2) distinct-lava filter
|
||||
## -> pathMaxHeat <= PathDangerThreshold -> draw set -> CHOSEN
|
||||
##
|
||||
## No battle, no Java, no server, no behaviour change: every knob it moves is
|
||||
## default-off or default-identical.
|
||||
##
|
||||
## Run:
|
||||
## nim c -r --path:common_libs --nimcache:/tmp/nc_j150 \
|
||||
## common_libs/tests/measure_tfil_picker_loss.nim [fixture.jsonl ...]
|
||||
|
||||
import std/[os, strformat, math, algorithm, json, sets, sequtils]
|
||||
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||
import gun_harness/offline_range
|
||||
# Private-field access: include (do NOT import) the shipped mover.
|
||||
include movements/the_floor_is_lava
|
||||
|
||||
proc loadRoundStarts(fixturePath: string): HashSet[int] =
|
||||
result = initHashSet[int]()
|
||||
let side = currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "drussgt_meta" /
|
||||
(extractFilename(fixturePath) & ".rounds.json")
|
||||
if not fileExists(side): return
|
||||
let root = parseFile(side)
|
||||
if not root.hasKey("rounds"): return
|
||||
for r in root["rounds"]:
|
||||
if r.hasKey("startTick"): result.incl r["startTick"].getInt()
|
||||
|
||||
proc replay(path: string, threshold: float): TfilLossStats =
|
||||
putEnv("TR_TFIL_DIAG", "1")
|
||||
putEnv("TR_TFIL_DANGER_THRESHOLD", $threshold)
|
||||
loadTfilCommitEnv()
|
||||
TfilLoss = TfilLossStats()
|
||||
let fx = loadFixture(path)
|
||||
let starts = loadRoundStarts(path)
|
||||
var m = initTFIL()
|
||||
for si in 0..<fx.states.len:
|
||||
if si == 0 or si in starts: m.resetRound()
|
||||
discard m.computeMove(fx.states[si])
|
||||
result = TfilLoss
|
||||
|
||||
proc mean(x: seq[float]): float =
|
||||
if x.len == 0: return 0.0
|
||||
var s = 0.0
|
||||
for v in x: s += v
|
||||
s / x.len.float
|
||||
|
||||
proc pctStr(x: float): string = &"{100.0 * x:.1f}%"
|
||||
|
||||
proc f2(x: float): string = &"{x:.2f}"
|
||||
|
||||
const SafeBuckets = ["0", "1", "2-3", "4-7", "8-15", "16-31", "32-63", "64+"]
|
||||
|
||||
proc heatTally(x: seq[float]): seq[(float, int)] =
|
||||
## distinct heat values -> how many rejected tiles carried it, most common
|
||||
## first. Lava is a sum of quantised terms, so this shows the real steps.
|
||||
var counts: seq[(float, int)]
|
||||
for v in x:
|
||||
var i = 0
|
||||
while i < counts.len and counts[i][0] != v: inc i
|
||||
if i < counts.len: inc counts[i][1]
|
||||
else: counts.add (v, 1)
|
||||
result = counts
|
||||
result.sort(proc (a, b: (float, int)): int = cmp(b[1], a[1]))
|
||||
|
||||
proc report(path: string) =
|
||||
let s = replay(path, 10.0)
|
||||
echo &"\n\u2550\u2550\u2550 {path}"
|
||||
if s.picks == 0:
|
||||
echo " no picks"; return
|
||||
let n = s.picks.float
|
||||
echo &" picks = {s.picks} (replayed ticks, one recorded battle, seed fixed)"
|
||||
echo &" stage mean tiles"
|
||||
echo &" 1 reachable-hull tiles {s.sReach.float/n:>12.2f}"
|
||||
echo &" 2 .. after CoolestLevels=2 filter {s.sCool.float/n:>12.2f}"
|
||||
echo &" 3 .. after path heat filter (pre-prom) {s.sSafe.float/n:>12.2f}"
|
||||
echo &" 4 .. draw set (what it chooses among) {s.sCand.float/n:>12.2f}"
|
||||
echo &" LOST at the 2-levels filter {(1.0 - s.sCool.float/s.sReach.float)*100:>11.1f}%"
|
||||
echo &" LOST at the heat filter {(1.0 - s.sSafe.float/max(1.0,s.sCool.float))*100:>11.1f}%"
|
||||
echo &" LOST at blocked-tile / no-rev {(1.0 - s.sCand.float/max(1.0,s.sSafe.float))*100:>11.1f}%"
|
||||
echo &" picks with an EMPTY safe set (<2 at stage 3) = {s.emptySafe} ({pctStr(s.emptySafe.float/n)})"
|
||||
echo " safe-set size distribution (stage 3):"
|
||||
for i in 0..<SafeBuckets.len:
|
||||
if s.safeHist[i] > 0:
|
||||
echo &" {SafeBuckets[i].alignLeft(6)} {s.safeHist[i]:>7} ({pctStr(s.safeHist[i].float/n)})"
|
||||
|
||||
# heat of the tiles the filter dropped
|
||||
echo &" heat of the {s.rejectedHeat.len} REJECTED tiles (mean {f2(mean(s.rejectedHeat))}), most common first:"
|
||||
for (v, c) in heatTally(s.rejectedHeat)[0 ..< min(8, s.rejectedHeat.len)]:
|
||||
echo &" heat {f2(v):>7} {c:>7} ({pctStr(c.float/s.rejectedHeat.len.float)})"
|
||||
echo &" heat of ADMITTED tiles mean {f2(mean(s.admittedHeat))} (n={s.admittedHeat.len}); " &
|
||||
&"of CHOSEN mean {f2(mean(s.chosenHeat))} (n={s.chosenHeat.len})"
|
||||
|
||||
# \u2550\u2550 threshold sweep
|
||||
echo " threshold mean draw set % empty safe newly admitted (n, mean heat) mean heat chosen"
|
||||
var baseAdmitted = replay(path, 10.0).admittedHeat.len
|
||||
for thr in [10.0, 14.0, 18.0]:
|
||||
let a = replay(path, thr)
|
||||
let newly = a.admittedHeat.filterIt(it > 10.0)
|
||||
let newTxt = $newly.len & ", " & f2(mean(newly))
|
||||
echo &" {thr:>9.0f} {a.sCand.float / a.picks.float:>13.2f} " &
|
||||
&"{pctStr(a.emptySafe.float / a.picks.float):>12} {newTxt:>30} {f2(mean(a.chosenHeat)):>16}"
|
||||
echo &" (admitted at threshold 10: {baseAdmitted} tiles)"
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
let args = commandLineParams()
|
||||
let fixtures = if args.len > 0: args
|
||||
else: @[currentSourcePath().parentDir.parentDir.parentDir /
|
||||
"tools" / "fixtures" / "tr_drussgt_vs_modularbot.jsonl"]
|
||||
for f in fixtures:
|
||||
if fileExists(f): report(f)
|
||||
else: echo "skip (missing): ", f
|
||||
@@ -0,0 +1,132 @@
|
||||
## j177 guard: the aim capture actually contains every field it promises, and
|
||||
## the default gun path is byte-for-byte unchanged.
|
||||
##
|
||||
## NO battle, NO Java, NO server, NO GUI.
|
||||
## nim c -r --path:common_libs --path:ModularBot_garage/src \
|
||||
## --nimcache:/tmp/nc_j177 common_libs/tests/test_aim_capture.nim
|
||||
##
|
||||
## Part 1 (the guard): with the capture ON, build one `aim_scan` and one
|
||||
## `aim_fire` row from a real recorded tick of
|
||||
## tools/fixtures/tr_drussgt_vs_modularbot.jsonl, through the SAME
|
||||
## `aim_capture` row builders ModularBot.nim calls, and assert every required
|
||||
## key is present and carries the value that was passed in. A capture that
|
||||
## silently omits a field is worse than none.
|
||||
##
|
||||
## Part 2 (default parity): replay the whole recorded fixture through the real
|
||||
## VirtualTracker + the guns with the capture OFF and compare the per-gun
|
||||
## fitness report against `fixtures/aim_capture_gunpath.golden`. That golden
|
||||
## was generated from the PRE-CHANGE tree (`git archive 55e92bc`) with the
|
||||
## knob unset — regenerating it from this code would defeat the check.
|
||||
## Part 2b: the same replay over a fixture that ALSO carries aim_scan/aim_fire
|
||||
## lines must give the identical report, i.e. the annotations are inert.
|
||||
|
||||
import std/[json, os, strutils, sequtils, strformat]
|
||||
import gun_harness/offline_range
|
||||
import range_guns
|
||||
import ../../ModularBot_garage/src/aim_capture
|
||||
|
||||
const
|
||||
repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||
fixture = repoRoot / "tools" / "fixtures" / fixtureRel
|
||||
goldenPath = currentSourcePath().parentDir / "fixtures" / "aim_capture_gunpath.golden"
|
||||
annotated = "/tmp/j177_annotated_fixture.jsonl"
|
||||
ScanKeys = ["tick", "eid", "ex", "ey", "eh", "es", "ee", "sx", "sy", "sh", "ss",
|
||||
"gun", "bx", "by", "bh", "bs", "blst", "age", "rlock", "rdir",
|
||||
"lbear", "boff"]
|
||||
FireKeys = ["tick", "eid", "gun", "power", "aim", "turret", "terr", "heat",
|
||||
"ax", "ay", "tof", "ex", "ey", "eh", "es", "ee", "sx", "sy", "lst"]
|
||||
|
||||
var failures = 0
|
||||
proc check(name: string, ok: bool) =
|
||||
if ok: echo "PASS: ", name
|
||||
else:
|
||||
echo "FAIL: ", name
|
||||
inc failures
|
||||
|
||||
proc replayReport(path: string): string =
|
||||
## Per-gun shots/hits over the whole fixture, through the REAL tracker and
|
||||
## the REAL guns, with the capture OFF.
|
||||
let res = replayFixture(loadFixture(path), buildAllGunDrivers(seed = 1))
|
||||
for r in res:
|
||||
result.add r.name & " shots=" & $r.shots & " hits=" & $r.hits & "\n"
|
||||
|
||||
# ── Part 1: the records carry every field ────────────────────────────────────
|
||||
proc fieldCheck() =
|
||||
let states = loadFixture(fixture).states
|
||||
check("fixture loaded", states.len > 1000)
|
||||
let ws = states[900]
|
||||
let scan = scanRow(AimScan(
|
||||
tick: 900, eid: 7,
|
||||
ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading, es: ws.enemySpeed,
|
||||
ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, sh: ws.selfHeading,
|
||||
ss: ws.selfSpeed, gun: 5,
|
||||
bx: ws.enemyX - 8.0, by: ws.enemyY - 8.0, bh: ws.enemyHeading,
|
||||
bs: ws.enemySpeed, blst: 896,
|
||||
rlock: true, rdir: 42.5, lbear: bearing(ws.enemyX - 8, ws.enemyY - 8, ws.selfX, ws.selfY),
|
||||
boff: -12.25))
|
||||
let fire = fireRow(AimFire(
|
||||
tick: 900, eid: 7, gun: 5, power: 1.6, aim: 88.25, turret: 74.0,
|
||||
terr: 14.25, heat: 0.31, ax: ws.enemyX + 40.0, ay: ws.enemyY - 15.0,
|
||||
tof: 12.5, ex: ws.enemyX, ey: ws.enemyY, eh: ws.enemyHeading,
|
||||
es: ws.enemySpeed, ee: ws.enemyEnergy, sx: ws.selfX, sy: ws.selfY, lst: 897))
|
||||
|
||||
for k in ScanKeys:
|
||||
check("aim_scan has " & k, scan[ScanRecordKey].hasKey(k))
|
||||
for k in FireKeys:
|
||||
check("aim_fire has " & k, fire[FireRecordKey].hasKey(k))
|
||||
|
||||
check("aim_scan carries the gun id", scan[ScanRecordKey]["gun"].getInt() == 5)
|
||||
check("aim_fire carries the gun id", fire[FireRecordKey]["gun"].getInt() == 5)
|
||||
check("aim_scan records the scan parity age",
|
||||
scan[ScanRecordKey]["age"].getInt() == 4)
|
||||
check("aim_fire records the source tick (parity)",
|
||||
fire[FireRecordKey]["lst"].getInt() == 897)
|
||||
check("aim_fire carries the aim angle", fire[FireRecordKey]["aim"].getFloat() == 88.25)
|
||||
check("aim_fire carries the turret error", fire[FireRecordKey]["terr"].getFloat() == 14.25)
|
||||
check("aim_fire carries the WorldState the model consumed",
|
||||
fire[FireRecordKey]["ex"].getFloat() == ws.enemyX and
|
||||
fire[FireRecordKey]["ey"].getFloat() == ws.enemyY)
|
||||
|
||||
echo "\n--- aim_scan record ---"
|
||||
echo $scan
|
||||
echo "--- aim_fire record ---"
|
||||
echo $fire
|
||||
echo ""
|
||||
|
||||
# ── Part 2: default gun-path parity ──────────────────────────────────────────
|
||||
proc parityCheck() =
|
||||
let clean = replayReport(fixture)
|
||||
let ticks = loadFixture(fixture).states.len
|
||||
if defined(aimCapGenGolden):
|
||||
var g = "# j177 gun-path default-parity golden.\n"
|
||||
g.add "# Generated from the PRE-CHANGE tree (`git archive 55e92bc`) with\n"
|
||||
g.add "# TR_CAPTURE_AIM unset, over the whole " & fixtureRel & ".\n"
|
||||
g.add "# Format: <gun> shots=<n> hits=<n>, one line per rack gun\n"
|
||||
g.add clean
|
||||
createDir(goldenPath.parentDir)
|
||||
writeFile(goldenPath, g)
|
||||
echo "wrote ", goldenPath, " (", ticks, " ticks)"
|
||||
return
|
||||
check("golden exists", fileExists(goldenPath))
|
||||
if not fileExists(goldenPath): return
|
||||
let g = lines(goldenPath).toSeq().filterIt(not it.startsWith("#")).join("\n").strip()
|
||||
check("gun path byte-for-byte identical over " & $ticks & " ticks", g == clean.strip())
|
||||
|
||||
# 2b: the annotation lines must be inert for the replay.
|
||||
let extra = @[
|
||||
$scanRow(AimScan(tick: 0, eid: 1, ex: 1.0, ey: 2.0, eh: 3.0, es: 4.0, ee: 5.0,
|
||||
sx: 6.0, sy: 7.0, sh: 8.0, ss: 9.0, gun: 3,
|
||||
bx: 0.0, by: 0.0, blst: -1, rlock: true, rdir: 1.0, lbear: 2.0)),
|
||||
$fireRow(AimFire(tick: 1, eid: 1, gun: 3, power: 1.5, aim: 1.0, turret: 2.0,
|
||||
terr: 3.0, heat: 0.0, ax: 1.0, ay: 1.0, tof: 1.0,
|
||||
ex: 1.0, ey: 1.0, eh: 1.0, es: 1.0, ee: 1.0,
|
||||
sx: 1.0, sy: 1.0, lst: 0))]
|
||||
writeFile(annotated, (lines(fixture).toSeq() & extra).join("\n"))
|
||||
check("aim records do not perturb the replay", replayReport(annotated) == clean)
|
||||
removeFile(annotated)
|
||||
|
||||
fieldCheck()
|
||||
parityCheck()
|
||||
echo (if failures == 0: "\nALL PASS" else: "\n" & $failures & " FAILURE(S)")
|
||||
quit(if failures == 0: 0 else: 1)
|
||||
@@ -37,11 +37,16 @@
|
||||
## A/B whose treatment did not apply is worthless) and that the soft
|
||||
## no-reversal preference can never empty the candidate pool.
|
||||
|
||||
import std/[os, json, random, math, sequtils]
|
||||
import std/[os, json, random, math, sequtils, sets]
|
||||
import std/strutils except fromHex # `fromHex` would clash with color.fromHex
|
||||
import gun_harness/gun_interface
|
||||
import movements/ram_decision
|
||||
# Private-field access: include (do NOT import) the shipped mover.
|
||||
include movements/the_floor_is_lava
|
||||
# j165: the TFIL-RING fork's arrival commitment. `tfil_ring_replay` includes
|
||||
# the ring mover (for its PRIVATE commitTarget/commitTicks) and re-exports it,
|
||||
# so this is the only ring import the guard needs.
|
||||
import tfil_ring_replay
|
||||
|
||||
const repoRoot = currentSourcePath().parentDir.parentDir.parentDir
|
||||
const fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||
@@ -848,6 +853,865 @@ when declared(loadTfilCommitEnv):
|
||||
" mean path heat=", meanPathHeat(s).formatFloat(ffDecimal, 2),
|
||||
" >90deg=", pct(s.bigTurn, s.picks)
|
||||
|
||||
## j147: TR_FIRE_LAG back-dates the ghost by the MEASURED detection lag (1
|
||||
## tick live: 1777/1777 matched spawns, `measure_fire_ghost_lag.py`). Default
|
||||
## 0 must be byte-for-byte today's spawn, and lag=n must move the ghost exactly
|
||||
## n bullet steps downrange — which is what shortens the arrival deadline,
|
||||
## because every mover derives the deadline from the ghost's own position.
|
||||
proc testJ147() =
|
||||
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
|
||||
enemySpeed: 0.0, enemyEnergy: 100.0,
|
||||
selfX: 400.0, selfY: 320.0, selfHeading: 0.0,
|
||||
selfSpeed: 0.0, selfEnergy: 100.0,
|
||||
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 1,
|
||||
enemies: @[])
|
||||
let ei = EnemyInfo(id: 1, x: 200.0, y: 320.0, heading: 0.0,
|
||||
speed: 0.0, energy: 100.0)
|
||||
const Power = 1.0
|
||||
let speed = 20.0 - 3.0 * Power # 17 px/tick
|
||||
|
||||
proc spawnGhost(): TrackedBullet =
|
||||
var m = initTFIL()
|
||||
discard m.computeMove(ws) # initGrid
|
||||
m.spawnTrackedWave(ws, ei, Power)
|
||||
m.bullets[^1]
|
||||
|
||||
# 1. default parity: unset -> 0, and the ghost is EXACTLY the scanned origin
|
||||
delEnv("TR_FIRE_LAG")
|
||||
loadFireTrackerEnv()
|
||||
check "j147: TR_FIRE_LAG unset -> FireLag 0", FireLag == 0
|
||||
let g0 = spawnGhost()
|
||||
check "j147: default (lag 0) puts the ghost exactly on the scanned enemy",
|
||||
g0.x == ei.x and g0.y == ei.y
|
||||
|
||||
# 2. lag 1 back-dates by EXACTLY one bullet step, on the ghost's own heading
|
||||
putEnv("TR_FIRE_LAG", "1")
|
||||
loadFireTrackerEnv()
|
||||
let g1 = spawnGhost()
|
||||
check "j147: TR_FIRE_LAG=1 places the ghost one bullet step downrange",
|
||||
FireLag == 1 and
|
||||
abs((g1.x - g0.x) - g1.velX) < 1e-9 and
|
||||
abs((g1.y - g0.y) - g1.velY) < 1e-9
|
||||
check "j147: ... and that step is the true bullet speed, not a scaled one",
|
||||
abs(sqrt(g1.velX * g1.velX + g1.velY * g1.velY) - speed) < 1e-9
|
||||
|
||||
# 3. the ARRIVAL DEADLINE shortens by exactly `lag` ticks. The mover's own
|
||||
# arrival proxy is dist(self, ghost) / speed (tfil `heatDecay(along/speed)`,
|
||||
# the `dot < 0` reap); the true bullet is one step further along.
|
||||
let etaGhost = sqrt((ws.selfX - g1.x)^2 + (ws.selfY - g1.y)^2) /
|
||||
sqrt(g1.velX * g1.velX + g1.velY * g1.velY)
|
||||
let etaTrue0 = sqrt((ws.selfX - g0.x)^2 + (ws.selfY - g0.y)^2) / speed
|
||||
let etaTrue1 = sqrt((ws.selfX - (g0.x + g0.velX))^2 +
|
||||
(ws.selfY - (g0.y + g0.velY))^2) / speed
|
||||
check "j147: with lag=1 the mover's deadline equals the TRUE remaining " &
|
||||
"flight (" & etaGhost.formatFloat(ffDecimal, 6) & " vs " &
|
||||
etaTrue1.formatFloat(ffDecimal, 6) & "), the lag-0 deadline being " &
|
||||
etaTrue0.formatFloat(ffDecimal, 6) & " — a full tick late",
|
||||
abs(etaGhost - etaTrue1) < 1e-9 and
|
||||
abs((etaTrue0 - etaTrue1) - 1.0) < 1e-9
|
||||
|
||||
# 4. lag n is n steps, and n=2 shortens the deadline by exactly 2
|
||||
putEnv("TR_FIRE_LAG", "2")
|
||||
loadFireTrackerEnv()
|
||||
let g2 = spawnGhost()
|
||||
check "j147: TR_FIRE_LAG=2 back-dates by two steps",
|
||||
abs((g2.x - g0.x) - 2.0 * g0.velX) < 1e-9 and
|
||||
abs((g2.y - g0.y) - 2.0 * g0.velY) < 1e-9
|
||||
let etaTrue2 = sqrt((ws.selfX - (g0.x + 2.0 * g0.velX))^2 +
|
||||
(ws.selfY - (g0.y + 2.0 * g0.velY))^2) / speed
|
||||
check "j147: ... so the deadline shortens by exactly 2 ticks",
|
||||
abs((etaTrue0 - etaTrue2) - 2.0) < 1e-9
|
||||
|
||||
# 5. a junk value falls back to 0, never to a negative/garbage back-date
|
||||
putEnv("TR_FIRE_LAG", "junk")
|
||||
loadFireTrackerEnv()
|
||||
let gj = spawnGhost()
|
||||
putEnv("TR_FIRE_LAG", "-4")
|
||||
loadFireTrackerEnv()
|
||||
let gn = spawnGhost()
|
||||
check "j147: a junk / negative TR_FIRE_LAG degrades to the shipped lag 0",
|
||||
FireLag == 0 and gj.x == ei.x and gn.x == ei.x
|
||||
|
||||
# 6. the ARRIVAL DEADLINE end-to-end: the ghost is reaped (`dot < 0`, the
|
||||
# geometric arrival) exactly `lag` ticks earlier, because it is `lag`
|
||||
# steps further along. This is the deadline the decision actually uses.
|
||||
proc ticksToReap(): int =
|
||||
var m = initTFIL()
|
||||
randomize(Seed)
|
||||
discard m.computeMove(ws)
|
||||
m.spawnTrackedWave(ws, ei, Power)
|
||||
for t in 1..80:
|
||||
discard m.computeMove(ws)
|
||||
if m.bullets.len == 0: return t
|
||||
99
|
||||
putEnv("TR_FIRE_LAG", "0")
|
||||
loadFireTrackerEnv()
|
||||
let reap0 = ticksToReap()
|
||||
putEnv("TR_FIRE_LAG", "1")
|
||||
loadFireTrackerEnv()
|
||||
let reap1 = ticksToReap()
|
||||
check "j147: the ghost arrives — and is reaped — exactly 1 tick earlier " &
|
||||
"with the back-date (" & $reap0 & " -> " & $reap1 & " ticks)",
|
||||
reap0 > 0 and reap1 > 0 and reap0 - reap1 == 1
|
||||
|
||||
# 7. restore the shipped default for every later check in this process
|
||||
delEnv("TR_FIRE_LAG")
|
||||
loadFireTrackerEnv()
|
||||
check "j147: clearing the knob restores the shipped spawn exactly",
|
||||
FireLag == 0 and spawnGhost().x == ei.x
|
||||
|
||||
# ── j150: the picker loss-histogram diag + the sweepable heat cutoff ──────────
|
||||
#
|
||||
# TR_TFIL_DIAG 0/1 default 0 — fill TfilLoss* only
|
||||
# TR_TFIL_DANGER_THRESHOLD (float) default 10 — was a proc-local `const`
|
||||
#
|
||||
# Both must be default-off-effect: the whole point of the diag is to measure
|
||||
# the shipped picker, not to change it.
|
||||
proc testJ150() =
|
||||
delEnv("TR_TFIL_DIAG"); delEnv("TR_TFIL_DANGER_THRESHOLD")
|
||||
loadTfilCommitEnv()
|
||||
check "j150: TR_TFIL_DIAG defaults OFF and TR_TFIL_DANGER_THRESHOLD defaults " &
|
||||
"to today's 10.0", (not TfilDiag) and TfilDangerThreshold == 10.0
|
||||
|
||||
# 1. the diag is PURE: identical move stream with it on and off
|
||||
let off = replay(loadStates(), loadRoundStarts())
|
||||
putEnv("TR_TFIL_DIAG", "1")
|
||||
loadTfilCommitEnv()
|
||||
let on = replay(loadStates(), loadRoundStarts())
|
||||
var diff = -1
|
||||
if off.len != on.len: diff = min(off.len, on.len)
|
||||
else:
|
||||
for i in 0..<off.len:
|
||||
if recLine(off[i]) != recLine(on[i]): diff = i; break
|
||||
check "j150: with TR_TFIL_DIAG=1 the move stream is BYTE-FOR-BYTE the " &
|
||||
"diag-off one over " & $off.len & " ticks — the counters are inert",
|
||||
diff < 0
|
||||
|
||||
# 2. the histogram is populated and its stage chain is monotone
|
||||
let st = TfilLoss # kept: step 4 clears the live counter
|
||||
check "j150: the histogram counted picks (" & $st.picks & ") and every " &
|
||||
"stage is non-increasing (reach >= cool-filter >= heat-filter)",
|
||||
st.picks > 0 and st.sReach >= st.sCool and
|
||||
st.sCool >= st.sSafe and st.sSafe <= st.sCand and
|
||||
st.safeHist[0] <= st.picks
|
||||
|
||||
# 3. knob parsing, including the fallbacks
|
||||
putEnv("TR_TFIL_DANGER_THRESHOLD", "18")
|
||||
loadTfilCommitEnv()
|
||||
check "j150: TR_TFIL_DANGER_THRESHOLD=18 is read", TfilDangerThreshold == 18.0
|
||||
putEnv("TR_TFIL_DANGER_THRESHOLD", "junk")
|
||||
loadTfilCommitEnv()
|
||||
check "j150: a malformed value falls back to the DEFAULT 10.0",
|
||||
TfilDangerThreshold == 10.0
|
||||
putEnv("TR_TFIL_DANGER_THRESHOLD", "-4")
|
||||
loadTfilCommitEnv()
|
||||
check "j150: a negative value clamps to 0 (heat can never go backwards)",
|
||||
TfilDangerThreshold == 0.0
|
||||
|
||||
# 4. restore the shipped default for every later check in this process
|
||||
putEnv("TR_TFIL_DIAG", ""); putEnv("TR_TFIL_DANGER_THRESHOLD", "")
|
||||
loadTfilCommitEnv()
|
||||
check "j150: clearing the knobs restores 10.0 / diag off",
|
||||
(not TfilDiag) and TfilDangerThreshold == 10.0 and TfilLoss.picks == 0
|
||||
|
||||
echo "\n j150 picker loss histogram (default build, offline fixture replay):"
|
||||
echo " picks=", st.picks
|
||||
let np = st.picks.float
|
||||
echo " mean reachable hull tiles=", st.sReach.float / np
|
||||
echo " mean after CoolestLevels=2 filter=", st.sCool.float / np
|
||||
echo " mean after the heat filter (pre-promotion)=", st.sSafe.float / np
|
||||
echo " mean draw set=", st.sCand.float / np
|
||||
|
||||
# ── j151: the ARRIVAL bound (TR_TFIL_ARRIVE_TICKS, default 0 = off) ─────────
|
||||
type ArrStats = object
|
||||
picks, beyond, starved: int ## starved = picks made with an empty pool
|
||||
meanTta, meanPool: float
|
||||
|
||||
proc replayJ151(bound: float): ArrStats =
|
||||
putEnv("TR_TFIL_ARRIVE_TICKS", $bound)
|
||||
loadTfilCommitEnv()
|
||||
let states = loadStates()
|
||||
let starts = loadRoundStarts()
|
||||
randomize(Seed)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
for i in 0..<states.len:
|
||||
if i == 0 or i in starts: m.resetRound()
|
||||
discard m.computeMove(states[i])
|
||||
if m.picks != lastPicks:
|
||||
lastPicks = m.picks
|
||||
let tta = sqrt((m.commitTarget.x - states[i].selfX)^2 +
|
||||
(m.commitTarget.y - states[i].selfY)^2) / MaxSpeed
|
||||
inc result.picks
|
||||
if tta > TfilArriveTicks + 0.001: inc result.beyond
|
||||
if m.lastPickSafe == 0: inc result.starved
|
||||
result.meanTta += tta
|
||||
result.meanPool += m.lastPickSafe.float
|
||||
if result.picks > 0:
|
||||
result.meanTta /= result.picks.float
|
||||
result.meanPool /= result.picks.float
|
||||
|
||||
proc testJ151() =
|
||||
# 8a. the shipped default is OFF — the golden parity check above is the proof
|
||||
delEnv("TR_TFIL_ARRIVE_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
check "j151: the arrival bound defaults to OFF (today's uniform draw over " &
|
||||
"the whole 50-tick hull)", TfilArriveTicks == 0.0
|
||||
|
||||
let off = replayJ151(0.0) # today's behaviour, same seed
|
||||
let on15 = replayJ151(15.0) # = CommitTicks: the horizon we hold a target for
|
||||
delEnv("TR_TFIL_ARRIVE_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
|
||||
# the CEILING, stated: the bound is a filter on the SAFE set, so a tick whose
|
||||
# every safe tile is past the horizon keeps the full pool (never starved) —
|
||||
# those picks stay long, and the guard below measures exactly how many.
|
||||
check "j151: picks past the 15-tick horizon collapse (" & $off.beyond & "/" &
|
||||
$off.picks & " -> " & $on15.beyond & "/" & $on15.picks & "); the " &
|
||||
"residue is the all-safe-tiles-are-far ticks, which keep the full pool",
|
||||
on15.beyond < off.beyond div 2 and off.beyond > 0
|
||||
check "j151: the mean time-to-arrive falls (" &
|
||||
off.meanTta.formatFloat(ffDecimal, 1) & " -> " &
|
||||
on15.meanTta.formatFloat(ffDecimal, 1) & " ticks) and the pool is " &
|
||||
"not starved (mean safe tiles " &
|
||||
on15.meanPool.formatFloat(ffDecimal, 1) & ", " & $on15.starved &
|
||||
" empty pools)",
|
||||
on15.meanTta < off.meanTta and on15.meanPool >= 1.0 and on15.starved == 0
|
||||
check "j151: the bound is a filter, not a replacement — the pick COUNT is " &
|
||||
"barely reduced (" & $off.picks & " -> " & $on15.picks & ")",
|
||||
on15.picks.float > off.picks.float * 0.9
|
||||
check "j151: clearing the knob restores today's pick stream exactly",
|
||||
replayJ151(0.0).picks == off.picks
|
||||
|
||||
# ── j152: the GEOMETRIC DRAW (TR_TFIL_GEO_MODE / TR_TFIL_GEO_TAU, default off) ─
|
||||
## Heat still gates the pool with the same hard filter; geometry only re-weights
|
||||
## the survivors of that filter — INCLUDING the 2 promoted least-hot tiles the
|
||||
## ~65% forced picks choose from, which is what j9 (`TR_TFIL_TURN_BIAS`) could
|
||||
## not see. What must hold:
|
||||
## 1. OFF by default and the OFF path is today's uniform draw byte-for-byte
|
||||
## (the golden check #1 above runs with the knobs unset and is that proof;
|
||||
## the last check here adds "off" == "unset" for the same seed).
|
||||
## 2. NO STARVATION: a pool in which EVERY tile is perpendicular still returns
|
||||
## a pick, in every form — the weight may never empty or index past the
|
||||
## pool, whatever the TAU.
|
||||
## 3. `off` and an all-ties pool both degenerate to the uniform draw.
|
||||
## 4. The form is parsed, and junk stays off.
|
||||
proc testJ152() =
|
||||
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
|
||||
loadTfilCommitEnv()
|
||||
check "j152: both geometry knobs default to OFF (today's uniform draw)",
|
||||
TfilGeoMode == gdoOff and TfilGeoTau == 0.0
|
||||
|
||||
check "j152: the mode string parses both axes (dim + form)",
|
||||
parseGeo("both-rej") == (gdoBoth, gfRej) and
|
||||
parseGeo("turn-topk") == (gdoTurn, gfTopK) and
|
||||
parseGeo("dist-soft") == (gdoDist, gfSoft) and
|
||||
parseGeo("turn") == (gdoTurn, gfSoft)
|
||||
check "j152: junk and 'off' both parse to OFF, never to a live arm",
|
||||
parseGeo("off").dim == gdoOff and parseGeo("sideways").dim == gdoOff
|
||||
|
||||
# 2. NO STARVATION: a pool where EVERY tile is 150 deg off the heading, at
|
||||
# three different distances. No form may return an index outside the pool.
|
||||
randomize(1)
|
||||
let allPerpT = @[150.0, 150.0, 150.0]
|
||||
let allPerpD = @[2.0, 30.0, 48.0]
|
||||
for form in [gfSoft, gfTopK, gfRej]:
|
||||
for tau in [1.0, 20.0, 5000.0]:
|
||||
var seen: seq[int]
|
||||
for _ in 0..<300:
|
||||
seen.add geoPick(allPerpT, allPerpD, gdoTurn, form, tau)
|
||||
# NO STARVATION = a pick always exists and is in range. It is NOT "every
|
||||
# tile stays reachable": topk and rej are hard forms BY DESIGN and may
|
||||
# legitimately return one tile forever when the whole pool is bad.
|
||||
check "j152: no starvation — an all-perpendicular pool still returns " &
|
||||
"an in-range pick (" & $form & ", tau " & $tau & ")",
|
||||
seen.len == 300 and seen.allIt(it in 0..2)
|
||||
|
||||
# 3. every tile costs the same => every weight ties => the uniform draw
|
||||
randomize(2)
|
||||
var tieSeen: seq[int]
|
||||
for _ in 0..<300:
|
||||
tieSeen.add geoPick(@[40.0, 40.0, 40.0], @[10.0, 10.0, 10.0], gdoTurn, gfSoft, 45.0)
|
||||
check "j152: an all-ties pool degenerates to the uniform draw (all 3 seen, " &
|
||||
"none starved)", tieSeen.toHashSet().len == 3
|
||||
var hitFar = 0
|
||||
randomize(3)
|
||||
for _ in 0..<400:
|
||||
if geoPick(@[0.0, 180.0], @[1.0, 1.0], gdoTurn, gfSoft, 10.0) == 0: inc hitFar
|
||||
check "j152: the soft form really tilts (a straight-ahead tile is drawn " &
|
||||
">" & $hitFar & "/400 of the time with tau=10)", hitFar > 300
|
||||
|
||||
# 4. "off" == "unset" for the same seed: the shipped stream, unchanged.
|
||||
let a = replayJ151(0.0)
|
||||
delEnv("TR_TFIL_GEO_MODE"); delEnv("TR_TFIL_GEO_TAU")
|
||||
loadTfilCommitEnv()
|
||||
let b = replayJ151(0.0)
|
||||
check "j152: geometry off reproduces the shipped draw exactly (same picks, " &
|
||||
"same mean tta, same pool)", a.picks == b.picks and
|
||||
a.meanTta == b.meanTta and a.meanPool == b.meanPool
|
||||
|
||||
# ── j153: HOLD WHEN TRAPPED (TR_TFIL_HOLD_WHEN_TRAPPED, default 0 = off) ────
|
||||
## The owner's rule: "if no tile is found to go, to not choose the less
|
||||
## dangerous, but to stay still! the next tick probably the situation already
|
||||
## changed and we did not commit to any dangerous place."
|
||||
## What must hold, and nothing more:
|
||||
## 1. OFF by default, and the OFF stream is byte-for-byte today's (the golden
|
||||
## check #1 above already proves the default path; this adds the explicit
|
||||
## "unset == 0 == 1-off-by-parsing" arm).
|
||||
## 2. ON + EMPTY safe set => no movement command for that tick.
|
||||
## 3. The hold is ONE tick: it never latches, and a later safe tile IS taken
|
||||
## (no stuck bot, no held-then-forever-silent).
|
||||
## 4. Holding does not skip the rest of the tick: the bullet tracking the GUN
|
||||
## and the lava field are updated exactly as on a non-held tick. (The gun
|
||||
## itself lives in the bot loop, not in this module — computeMove never
|
||||
## emits a fire command — so the real risk is a hold that `return`s too
|
||||
## early and freezes the bullet tracker; that is what this checks.)
|
||||
type HoldRec = object
|
||||
call: int
|
||||
spd, trn: float
|
||||
held: bool
|
||||
picked: bool
|
||||
bullets: int ## tracked bullets after this tick (the fire tracker's)
|
||||
|
||||
proc replayJ153(hold: bool): seq[HoldRec] =
|
||||
putEnv("TR_TFIL_HOLD_WHEN_TRAPPED", (if hold: "1" else: "0"))
|
||||
loadTfilCommitEnv()
|
||||
let states = loadStates()
|
||||
let starts = loadRoundStarts()
|
||||
randomize(Seed)
|
||||
var m = initTFIL()
|
||||
var lastPicks = 0
|
||||
for i in 0..<states.len:
|
||||
if i == 0 or i in starts:
|
||||
m.resetRound()
|
||||
lastPicks = 0 # resetRound zeroes `picks`: not a new pick
|
||||
let cmd = m.computeMove(states[i])
|
||||
result.add HoldRec(call: m.callCount, spd: cmd.speed, trn: cmd.turnRate, held: m.lastHeld,
|
||||
picked: m.picks != lastPicks, bullets: m.bullets.len)
|
||||
lastPicks = m.picks
|
||||
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
||||
loadTfilCommitEnv()
|
||||
|
||||
proc testJ153() =
|
||||
delEnv("TR_TFIL_HOLD_WHEN_TRAPPED")
|
||||
loadTfilCommitEnv()
|
||||
check "j153: TR_TFIL_HOLD_WHEN_TRAPPED defaults to OFF (today's " &
|
||||
"promote-the-2-least-hot fallback)", not TfilHoldWhenTrapped
|
||||
|
||||
let off = replayJ153(false) # knob explicitly 0
|
||||
let unset = replay(loadStates(), loadRoundStarts()) # knob never set
|
||||
var diff = -1
|
||||
if off.len != unset.len: diff = min(off.len, unset.len)
|
||||
else:
|
||||
for i in 0..<off.len:
|
||||
if unset[i].spd != off[i].spd or unset[i].trn != off[i].trn or
|
||||
unset[i].call != off[i].call:
|
||||
diff = i; break
|
||||
check "j153: with the knob unset the move stream is BYTE-FOR-BYTE the " &
|
||||
"knob-0 one over " & $off.len & " ticks — the default is today's",
|
||||
diff < 0 and off.len > 0
|
||||
|
||||
let on = replayJ153(true)
|
||||
var held, heldMoved, heldPicked = 0
|
||||
var nonHeldMoving = 0
|
||||
for i in 0..<on.len:
|
||||
if on[i].held:
|
||||
inc held
|
||||
if abs(on[i].spd) > 0.001: inc heldMoved
|
||||
if on[i].picked: inc heldPicked
|
||||
elif abs(on[i].spd) > 0.001: inc nonHeldMoving
|
||||
check "j153: with the knob ON the safe set really is empty often enough to " &
|
||||
"matter (" & $held & " held ticks of " & $on.len & ")",
|
||||
held > on.len div 100
|
||||
check "j153: a held tick emits NO movement (speed 0) and no pick " &
|
||||
"(" & $heldMoved & " moving holds, " & $heldPicked & " held picks)",
|
||||
held > 0 and heldMoved == 0 and heldPicked == 0
|
||||
check "j153: the hold is not a freeze — " & $nonHeldMoving & " non-held " &
|
||||
"ticks still move and the bot still picks",
|
||||
nonHeldMoving > 0 and on.filterIt(it.picked).len > 0
|
||||
|
||||
# no latch: a held tick must be followed by movement again (within a couple of
|
||||
# ticks), and a pick must still be taken somewhere after the holds.
|
||||
# A HOLD is not a latch: the hold is decided at the pick site, and the pick
|
||||
# site only runs when the commitment has expired, so every held tick is a FRESH
|
||||
# evaluation of the field. Observable consequence: hold runs end, and the tick
|
||||
# after a run is a moving tick again. (A latching implementation would show ONE
|
||||
# run per round and ~0 resumptions.) A long run therefore means a long trap, not
|
||||
# a stuck bot — that is why the run LENGTH is deliberately not asserted.
|
||||
var runs = 0
|
||||
var resumed = 0
|
||||
var worst = 0
|
||||
var run = 0
|
||||
for i in 0..<on.len:
|
||||
if on[i].held:
|
||||
inc run
|
||||
else:
|
||||
if run > 0:
|
||||
inc runs
|
||||
worst = max(worst, run)
|
||||
if abs(on[i].spd) > 0.001: inc resumed
|
||||
run = 0
|
||||
if run > 0:
|
||||
inc runs
|
||||
worst = max(worst, run)
|
||||
var pickedAfter = 0
|
||||
var sawHold = false
|
||||
for r in on:
|
||||
if r.held: sawHold = true
|
||||
elif sawHold and r.picked: inc pickedAfter
|
||||
check "j153: the hold is NOT a latch — " & $resumed & " of " & $runs &
|
||||
" maximal hold runs resume moving on the very next tick (longest run " &
|
||||
$worst & " ticks = a trap that lasts, not a stuck bot) and " &
|
||||
$pickedAfter & " picks happen after a hold",
|
||||
runs > 0 and resumed * 2 > runs and pickedAfter > 0
|
||||
|
||||
# the gun path: a held tick must leave the bullet tracker exactly where a
|
||||
# non-held tick would. If the hold returned before the tracker update, the
|
||||
# bullet counts would diverge from the first hold onwards.
|
||||
var firstDiv = -1
|
||||
for i in 0..<min(on.len, off.len):
|
||||
if on[i].bullets != off[i].bullets:
|
||||
if on[i].held or off[i].held: firstDiv = i
|
||||
break
|
||||
check "j153: holding does not freeze the fire/bullet bookkeeping the GUN " &
|
||||
"reads (bullet counts identical on held vs non-held ticks)",
|
||||
firstDiv < 0
|
||||
|
||||
# ── j154: the BOUNDED hold (TR_TFIL_HOLD_MAX_TICKS, default 0 = off) ────────
|
||||
#
|
||||
# The budget is DERIVED from the enemy's own rate of fire, not chosen. Server
|
||||
# `rules/math.kt`: `calcGunHeat(p) = 1 + p/5`, `calcBulletDamage(3.0) = 16`;
|
||||
# `core/GunEngine.kt`: the gun cools 0.1 per tick and may only fire at heat == 0.
|
||||
# So two 3.0-power shots are 1.6/0.1 = 16 ticks apart, and a brute-force search
|
||||
# over the 0.1 power quantisation says 32 damage is the most the enemy can land
|
||||
# in any 16-tick window (8/11/16/24/32 ticks -> 16/18/32/32/48). 16 is also the
|
||||
# FIRST window that admits the enemy's SECOND shot at all, so nothing shorter
|
||||
# can be surprised by a third bullet.
|
||||
#
|
||||
# A fully hot field is painted with the virtual pillar at radiance 0
|
||||
# (`max(0, hotness - d*0) = hotness` on every tile) — the one heat source that
|
||||
# covers the whole reachable hull at once, so the safe set is provably empty.
|
||||
proc testJ154() =
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS defaults to 0 = today's behaviour exactly",
|
||||
TfilHoldMaxTicks == 0 and not TfilHoldWhenTrapped
|
||||
|
||||
# 1. DEFAULT PARITY: an explicit 0 is indistinguishable from unset, over the
|
||||
# whole fixture, tick for tick. (The golden above covers UNSET; this covers
|
||||
# the explicit zero the owner would put in an arm.)
|
||||
let unset = replay(loadStates(), loadRoundStarts())
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "0")
|
||||
loadTfilCommitEnv()
|
||||
let zero = replay(loadStates(), loadRoundStarts())
|
||||
var diff = -1
|
||||
if unset.len != zero.len: diff = min(unset.len, zero.len)
|
||||
else:
|
||||
for i in 0..<unset.len:
|
||||
if recLine(unset[i]) != recLine(zero[i]): diff = i; break
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS=0 is BYTE-FOR-BYTE the unset build over " &
|
||||
$unset.len & " ticks (default path unchanged)",
|
||||
diff < 0 and unset.len > 0
|
||||
|
||||
# 2. knob parsing
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16"); loadTfilCommitEnv()
|
||||
check "j154: TR_TFIL_HOLD_MAX_TICKS=16 is read", TfilHoldMaxTicks == 16
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "junk"); loadTfilCommitEnv()
|
||||
check "j154: a malformed value falls back to 0 (off)", TfilHoldMaxTicks == 0
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "-8"); loadTfilCommitEnv()
|
||||
check "j154: a negative value clamps to 0", TfilHoldMaxTicks == 0
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS"); loadTfilCommitEnv()
|
||||
|
||||
const HoldN = 4 ## the budget under test; any small N exercises it
|
||||
const Hot = 100.0 ## every tile at 100 >> PathDangerThreshold 10
|
||||
let ws = WorldState(enemyX: 0.0, enemyY: 0.0, enemyHeading: 0.0,
|
||||
enemySpeed: 0.0, enemyEnergy: 100.0,
|
||||
selfX: 400.0, selfY: 300.0, selfHeading: 0.0,
|
||||
selfSpeed: 8.0, selfEnergy: 100.0,
|
||||
arenaWidth: ArenaW, arenaHeight: ArenaH, tick: 0,
|
||||
enemies: @[])
|
||||
|
||||
## One tick. `hot` = the pillar heat (0 = a clean field). A non-nil `bullet`
|
||||
## is installed as the tracked set, which is the ONLY way an inbound bullet
|
||||
## ever exists here (the enemy is out of the arena in these worlds).
|
||||
proc tick(m: var TFILModule, hot: float, t: int,
|
||||
bullet: TrackedBullet = TrackedBullet(alive: false)): MoveCommand =
|
||||
PillarHotness = hot
|
||||
PillarRadiance = 0.0
|
||||
var w = ws
|
||||
w.tick = t
|
||||
if bullet.alive: m.bullets = @[bullet]
|
||||
result = m.computeMove(w)
|
||||
|
||||
## A module with the grid initialised and NO live commitment, so tick 0 of a
|
||||
## scenario is a REPLAN tick (where, and only where, a hold may be taken).
|
||||
proc fresh(): TFILModule =
|
||||
PillarHotness = 0.0; PillarRadiance = 0.0
|
||||
result = initTFIL()
|
||||
randomize(Seed)
|
||||
discard tick(result, 0.0, 0)
|
||||
result.commitTicks = 0
|
||||
result.picks = 0
|
||||
result.commitTarget = (x: 400.0, y: 300.0)
|
||||
|
||||
type Rec = tuple[held: bool, ht: int, picked: bool]
|
||||
|
||||
## The held/pick pattern of a run, plus the counter at each tick.
|
||||
proc run(m: var TFILModule, hot: seq[float]): seq[Rec] =
|
||||
for t, h in hot:
|
||||
let before = m.picks
|
||||
discard tick(m, h, t + 1)
|
||||
result.add (held: m.lastHeld, ht: m.holdTicks, picked: m.picks != before)
|
||||
|
||||
# A 1-tick commitment makes every tick a replan tick, so the scenario is a
|
||||
# clean read of the hold rule alone (no commitment state leaking in).
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "1")
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", $HoldN)
|
||||
loadTfilCommitEnv()
|
||||
|
||||
# 3. the BOUND: at most N consecutive held ticks, then the normal promote-the-2
|
||||
# fallback takes over — the hold can never latch.
|
||||
let hotAll = @[Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0,
|
||||
Hot, Hot, Hot, Hot, Hot, Hot, 0.0, 0.0, 0.0]
|
||||
var m = fresh()
|
||||
let r = run(m, hotAll)
|
||||
var firstPick = -1
|
||||
for i, e in r:
|
||||
if e.picked: firstPick = i; break
|
||||
echo "\n j154 run (held/ht/picked per tick, index: value):"
|
||||
for i, e in r:
|
||||
echo " ", i, ": ", (if e.held: "H" else: "."), e.ht,
|
||||
(if e.picked: " P" else: " ")
|
||||
check "j154: with the safe set EMPTY the mover HOLDS (" & $HoldN &
|
||||
" ticks) instead of promoting, and releases into a pick on tick " &
|
||||
$(firstPick + 1) & " — the bound is N, not 'until a tile appears'",
|
||||
firstPick == HoldN and r[0].held and r[HoldN - 1].held and
|
||||
r[HoldN - 1].ht == HoldN and not r[HoldN].held and r[HoldN].picked
|
||||
|
||||
# 4. RELEASE THE MOMENT A SAFE TILE EXISTS: the field cools at index 10, and
|
||||
# that very tick is a pick, not a hold — no tick of latency.
|
||||
check "j154: the hold releases on the SAME tick a safe tile appears " &
|
||||
"(index 10 cooled -> picked=" & $r[10].picked & ", held=" &
|
||||
$r[10].held & ", counter=" & $r[10].ht & ")",
|
||||
r[10].picked and not r[10].held and r[10].ht == 0
|
||||
# ... and the budget REFILLS: a fresh empty streak holds a full N again,
|
||||
# i.e. the bound is per streak and the counter is not cumulative.
|
||||
let streak2 = r[11 .. ^1]
|
||||
var held2 = 0
|
||||
for e in streak2:
|
||||
if e.held: inc held2
|
||||
check "j154: the counter RESET when the safe tile was taken — the second " &
|
||||
"empty streak holds a full N again (" & $held2 & " ticks), never the " &
|
||||
"accumulated " & $r[10].ht & "+" & $r[11].ht,
|
||||
streak2[0].held and held2 >= HoldN and r[11].ht == 1
|
||||
|
||||
# 5. the held command is the SAME stop the mover already emits at its target,
|
||||
# and the gun path is untouched: on a held tick the fire detector still
|
||||
# latches the enemy's wave (the bot aims and fires from tracked state after
|
||||
# go(), on every tick, whatever speed it just commanded).
|
||||
m = fresh()
|
||||
PillarHotness = Hot; PillarRadiance = 0.0
|
||||
var wFar = ws
|
||||
wFar.enemies = @[EnemyInfo(id: 1, x: 760.0, y: 300.0, heading: 180.0,
|
||||
speed: 0.0, energy: 100.0)]
|
||||
wFar.tick = 1
|
||||
discard m.computeMove(wFar) # enemy seen at 100.0 energy
|
||||
m.commitTicks = 0 # armed: a replan tick, as above
|
||||
wFar.tick = 2
|
||||
wFar.enemies[0].energy = 98.5 # a 1.5 drop = a 1.5-power shot
|
||||
let gunCmd = m.computeMove(wFar)
|
||||
check "j154: the gun still fires while holding — the mover held (" &
|
||||
$m.lastHeld & ") on the very tick the enemy fired, and the fire " &
|
||||
"detector still latched the wave (" & $m.bullets.len & " tracked)",
|
||||
m.lastHeld and m.bullets.len > 0
|
||||
check "j154: the held command is the stop the mover already emits at its " &
|
||||
"target (speed 0, turn 0) — no new signal, no movement side effect",
|
||||
gunCmd.speed == 0.0 and gunCmd.turnRate == 0.0
|
||||
|
||||
# 6. PANIC RELEASE (required). A tracked bullet on a collision course, 9 ticks
|
||||
# out, overrides the hold on the tick it exists. The same bullet offset
|
||||
# laterally does NOT, so the release is specific, not "any bullet".
|
||||
# The budget is the DERIVED 16 here, so the horizon is min(16, 16) = 16.
|
||||
putEnv("TR_TFIL_HOLD_MAX_TICKS", "16")
|
||||
loadTfilCommitEnv()
|
||||
let inbound = TrackedBullet(originX: 570.0, originY: 300.0, x: 570.0, y: 300.0,
|
||||
velX: -17.0, velY: 0.0, power: 1.0,
|
||||
alive: true, age: 0)
|
||||
let missing = TrackedBullet(originX: 570.0, originY: 500.0, x: 570.0,
|
||||
y: 500.0, velX: -17.0, velY: 0.0, power: 1.0,
|
||||
alive: true, age: 0)
|
||||
m = fresh()
|
||||
discard tick(m, Hot, 1, inbound)
|
||||
check "j154: PANIC RELEASE — a tracked bullet 9 ticks from our position " &
|
||||
"overrides the hold on the same tick (held=" & $m.lastHeld &
|
||||
", picked=" & $(m.picks > 0) & ")",
|
||||
(not m.lastHeld) and m.picks > 0 and m.holdTicks == 0
|
||||
m = fresh()
|
||||
let cmdMiss = tick(m, Hot, 1, missing)
|
||||
check "j154: ... and it is SPECIFIC: the same bullet 200px off our line " &
|
||||
"still holds (held=" & $m.lastHeld & "), so the release is an arrival " &
|
||||
"test, not a bullet count",
|
||||
m.lastHeld and m.picks == 0 and cmdMiss.speed == 0.0
|
||||
|
||||
proc withBullet(b: TrackedBullet): TFILModule =
|
||||
result = initTFIL()
|
||||
result.bullets = @[b]
|
||||
check "j154: the panic horizon is the DERIVED budget min(N, 16) ticks — the " &
|
||||
"arrival test fires inside it and not outside",
|
||||
bulletPanic(initTFIL(), 400.0, 300.0, 16.0) == false and
|
||||
bulletPanic(withBullet(inbound), 400.0, 300.0, 16.0) == true and
|
||||
bulletPanic(withBullet(inbound), 400.0, 300.0, 4.0) == false
|
||||
|
||||
# 7. a hold NEVER interrupts a live commitment (j153's comment claimed that;
|
||||
# j154's code enforces it). Take a pick, keep the field hot, and the mover
|
||||
# must keep driving to its committed target.
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "15")
|
||||
loadTfilCommitEnv()
|
||||
m = fresh()
|
||||
discard tick(m, 0.0, 1) # a clean field first: that tick PICKS
|
||||
let pickedFirst = m.picks > 0
|
||||
let live = m.commitTicks
|
||||
let cmdLive = tick(m, Hot, 2) # now the field goes fully hot
|
||||
check "j154: a hold never interrupts a live commitment — with " & $live &
|
||||
" ticks on the clock the mover keeps driving to its target (speed " &
|
||||
$cmdLive.speed & "), it does not freeze",
|
||||
pickedFirst and live > 0 and (not m.lastHeld) and cmdLive.speed != 0.0
|
||||
|
||||
putEnv("TR_TFIL_COMMIT_TICKS", "15")
|
||||
delEnv("TR_TFIL_HOLD_MAX_TICKS")
|
||||
loadTfilCommitEnv()
|
||||
PillarHotness = 0.0; PillarRadiance = 0.0
|
||||
check "j154: clearing the knob restores today's behaviour exactly",
|
||||
TfilHoldMaxTicks == 0
|
||||
|
||||
# ── j165: the TFIL-RING arrival commitment (TR_TFIL_RING_*, default OFF) ─────
|
||||
# The same two mechanisms ported from `the_floor_is_lava.nim` (j144) onto
|
||||
# RING-SPECIFIC env names, so the two forks never share a namespace by
|
||||
# accident. What must hold, and nothing more:
|
||||
# 1. DEFAULT PARITY: with both knobs unset the ring mover is byte-for-byte
|
||||
# the PRE-CHANGE ring mover over the whole fixture replay.
|
||||
# 2. the arrival commitment ENGAGES: the committed target is actually
|
||||
# REACHED far more often, and there are strictly fewer mid-flight
|
||||
# re-targets than the shipped fixed 5-tick dwell.
|
||||
# 3. the no-reversal pool is SPEED-GATED: the stream may only diverge from
|
||||
# the unarmed build on a tick whose |selfSpeed| is below the gate, and the
|
||||
# pool itself can never be emptied.
|
||||
when declared(TfilRingCommitArrival):
|
||||
|
||||
proc ringRunStats(): tuple[picks, reached: int] =
|
||||
## Count picks over the replay and how many of them REPLACED a target the bot
|
||||
## had actually stood on (< 18px, the same arrival radius the mover uses).
|
||||
## Under a fixed dwell this is rare: the target is replaced mid-flight.
|
||||
randomize(Seed)
|
||||
var m = initTFILRing()
|
||||
let states = loadStates()
|
||||
let starts = loadRoundStarts()
|
||||
var prev = (x: 0.0, y: 0.0)
|
||||
var hadPick = false
|
||||
for i in 0..<states.len:
|
||||
if i == 0 or i in starts:
|
||||
m.resetRound()
|
||||
hadPick = false
|
||||
let ws = states[i]
|
||||
let before = ringPicks(m)
|
||||
discard m.computeMove(ws)
|
||||
if ringPicks(m) != before:
|
||||
inc result.picks
|
||||
if hadPick and
|
||||
sqrt((ws.selfX - prev.x)^2 + (ws.selfY - prev.y)^2) < RingArriveRadius:
|
||||
inc result.reached
|
||||
prev = ringTarget(m)
|
||||
hadPick = true
|
||||
|
||||
proc testJ165() =
|
||||
doAssert fileExists(RingGoldenPath), "missing golden: " & RingGoldenPath
|
||||
let recs = replayRing() # both knobs unset; the shipped fire detector, as
|
||||
# the golden was generated
|
||||
var golden: seq[string]
|
||||
for rawLine in lines(RingGoldenPath):
|
||||
if rawLine.startsWith("#"): continue
|
||||
let line = rawLine.strip()
|
||||
if line.len > 0: golden.add line
|
||||
check "j165: ring golden covers the whole fixture (>= 15000 ticks)",
|
||||
golden.len >= 15000
|
||||
check "j165: the unset replay covers the same number of ticks",
|
||||
recs.len == golden.len
|
||||
var firstDiff = -1
|
||||
for i in 0..<min(recs.len, golden.len):
|
||||
if ringRecLine(recs[i]) != golden[i]:
|
||||
firstDiff = i
|
||||
break
|
||||
check "j165: BOTH KNOBS UNSET IS BYTE-FOR-BYTE THE PRE-CHANGE RING MOVER " &
|
||||
"(speed/turnRate/target/commitTicks) over " & $recs.len & " ticks",
|
||||
firstDiff < 0
|
||||
if firstDiff >= 0:
|
||||
echo " first divergence at tick index ", firstDiff, ": got [",
|
||||
ringRecLine(recs[firstDiff]), "] want [", golden[firstDiff], "]"
|
||||
|
||||
delEnv("TR_TFIL_RING_COMMIT_ARRIVAL")
|
||||
delEnv("TR_TFIL_RING_NOREV_SPEED")
|
||||
loadTfilRingCommitEnv()
|
||||
check "j165: both knobs DEFAULT OFF with the env deleted",
|
||||
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
|
||||
putEnv("TR_TFIL_COMMIT_ARRIVAL", "1") # tfil's names must NOT leak across
|
||||
putEnv("TR_TFIL_NOREV_SPEED", "9")
|
||||
loadTfilRingCommitEnv()
|
||||
check "j165: the env names are RING-SPECIFIC — tfil's " &
|
||||
"TR_TFIL_COMMIT_ARRIVAL / TR_TFIL_NOREV_SPEED leave ring untouched",
|
||||
(not TfilRingCommitArrival) and TfilRingNoRevSpeed == 0.0
|
||||
delEnv("TR_TFIL_COMMIT_ARRIVAL")
|
||||
delEnv("TR_TFIL_NOREV_SPEED")
|
||||
|
||||
# 2. the arrival commitment engages
|
||||
let off = ringRunStats()
|
||||
TfilRingCommitArrival = true
|
||||
let on = ringRunStats()
|
||||
TfilRingCommitArrival = false
|
||||
check "j165: ARRIVAL ENGAGES — a committed target is actually REACHED on " &
|
||||
$on.reached & "/" & $on.picks & " picks, vs " & $off.reached & "/" &
|
||||
$off.picks & " on the shipped fixed 5-tick dwell",
|
||||
on.picks > 0 and (on.reached.float / on.picks.float) >
|
||||
(off.reached.float / off.picks.float)
|
||||
check "j165: ... and it holds instead of re-targeting mid-flight: " &
|
||||
$on.picks & " picks vs " & $off.picks & " on the same replay",
|
||||
on.picks < off.picks
|
||||
|
||||
# 3. the no-reversal pool is speed-gated, and can never be emptied
|
||||
check "j165: norevPool with the gate off returns EVERY candidate",
|
||||
norevPool(@[10.0, 120.0, -170.0], 0.0) == @[0, 1, 2]
|
||||
check "j165: norevPool armed keeps only the non-reversing candidates",
|
||||
norevPool(@[10.0, 120.0, -170.0], 4.0) == @[0]
|
||||
check "j165: norevPool never empties — all-behind falls back to the least bad",
|
||||
norevPool(@[170.0, 150.0, 179.0], 4.0) == @[1] and
|
||||
norevPool(@[170.0, 179.0], 4.0).len > 0
|
||||
# Engine gate: the streams can only DIVERGE at a gated pick. (Divergence
|
||||
# then persists for many ticks — a different target steers differently — so
|
||||
# "every diverging tick is slow" is the wrong claim; "the FIRST divergence
|
||||
# is a slow-tick pick" is the right one.)
|
||||
TfilRingNoRevSpeed = 4.0
|
||||
let armed = replayRing()
|
||||
TfilRingNoRevSpeed = 0.0
|
||||
var nDiv = 0
|
||||
var firstArmed = -1
|
||||
for i in 0..<min(recs.len, armed.len):
|
||||
if ringRecLine(recs[i]) != ringRecLine(armed[i]):
|
||||
inc nDiv
|
||||
if firstArmed < 0: firstArmed = i
|
||||
let states = loadRingStates()
|
||||
check "j165: the no-reversal treatment APPLIES (gate 4.0 changes " & $nDiv &
|
||||
" of " & $recs.len & " ticks — an A/B whose treatment never fires is worthless)",
|
||||
nDiv > 0
|
||||
check "j165: ... and it is SPEED-GATED: the first divergence (tick " &
|
||||
$firstArmed & ") is a PICK made at |selfSpeed| = " &
|
||||
$(if firstArmed >= 0: abs(states[firstArmed].selfSpeed) else: -1.0) &
|
||||
" < 4.0",
|
||||
firstArmed >= 0 and armed[firstArmed].picked and
|
||||
abs(states[firstArmed].selfSpeed) < 4.0
|
||||
|
||||
# ── j160: the energy-reserve FIRING FLOOR + ENEMY-EXHAUSTION ram trigger ─────
|
||||
proc testJ160() =
|
||||
## j160 — the energy-reserve FIRING FLOOR (TR_RAM_FLOOR_ENERGY) and the
|
||||
## ENEMY-EXHAUSTION ram trigger (TR_RAM_ENEMY_ENERGY). Both default 0.0 =
|
||||
## today's behaviour. Pure logic only; no bot, no server.
|
||||
const F = 5.0
|
||||
|
||||
# 1. DEFAULT PARITY, floor: with the knob unset the floor blocks NOTHING over
|
||||
# a grid that includes every measured low-energy region (<=5: 9.4% of
|
||||
# ticks in the 8612-round closed-loop corpus, p01 self energy = 0.2).
|
||||
var blocked = 0
|
||||
for e in [-1.0, 0.0, 0.1, 1.0, 2.5, 5.0, 7.0, 20.0, 46.0, 100.0, 120.0]:
|
||||
if fireFloorBlocks(0.0, e): inc blocked
|
||||
check "j160: TR_RAM_FLOOR_ENERGY unset (=0) suppresses fire on NO input, " &
|
||||
"so the default path is byte-for-byte today's (" & $blocked & " blocked)",
|
||||
blocked == 0
|
||||
|
||||
# 2. DEFAULT PARITY, trigger: with the knob unset the reason over a grid is
|
||||
# the PRE-j160 result — the new arm is unreachable, and every old arm still
|
||||
# returns exactly what it returned before.
|
||||
var newArm, mismatch: int
|
||||
for dist in [10.0, 100.0, 299.0, 301.0, 500.0]:
|
||||
for se in [0.5, 5.0, 19.0, 21.0, 60.0, 100.0]:
|
||||
for ee in [0.0, 1.0, 5.0, 19.9, 20.0, 40.0, 100.0]:
|
||||
let inp = RamInputs(dist: dist, selfEnergy: se, enemyEnergy: ee)
|
||||
let got = ramTrigger(inp)
|
||||
if got == rrExhausted: inc newArm
|
||||
# the pre-j160 body, verbatim
|
||||
var want: RamReason = rrNone
|
||||
if ee > 0.0:
|
||||
if dist < RamFinisherDist and ee < RamFinisherEnergy and se > ee: want = rrFinisher
|
||||
elif se < RamDesperationEnergy and ee < RamDesperationEnergy and dist < RamDesperationDist: want = rrDesperation
|
||||
if got != want: inc mismatch
|
||||
check "j160: TR_RAM_ENEMY_ENERGY unset (=0) makes the exhaustion arm " &
|
||||
"unreachable (" & $newArm & " hits) and leaves the old finisher / " &
|
||||
"desperation verdicts identical (" & $mismatch & " mismatches over 210 " &
|
||||
"input combinations)",
|
||||
newArm == 0 and mismatch == 0
|
||||
|
||||
# 3. the floor suppresses AT the threshold, not above it.
|
||||
check "j160: the floor blocks AT the threshold (self == 5.0 <= floor 5.0)",
|
||||
fireFloorBlocks(F, 5.0)
|
||||
check "j160: the floor blocks just below it and not just above it — one " &
|
||||
"tick of hysteresis, no dead band",
|
||||
fireFloorBlocks(F, 4.999) and not fireFloorBlocks(F, 5.001)
|
||||
|
||||
# 4. it never suppresses while we are healthy, at ANY floor setting.
|
||||
var healthy = 0
|
||||
for floor in [0.5, 1.0, 5.0, 20.0, 25.0, 40.0]:
|
||||
for e in [floor, 46.0, 60.0, 100.0, 120.0]:
|
||||
if e > floor and fireFloorBlocks(floor, e): inc healthy
|
||||
check "j160: the floor NEVER blocks above its own threshold — healthy energy " &
|
||||
"fires for every floor/energy pair (" & $healthy & " violations)",
|
||||
healthy == 0
|
||||
|
||||
# 5. the trigger switches to ram EXACTLY at the tolerance, no earlier.
|
||||
let inp2 = RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.0)
|
||||
check "j160: the exhaustion trigger fires EXACTLY at TR_RAM_ENEMY_ENERGY " &
|
||||
"(enemy 10.0 <= tol 10.0) and not one tick above (10.001)",
|
||||
ramTrigger(inp2, enemyEnergyTol = 10.0) == rrExhausted and
|
||||
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 10.001),
|
||||
enemyEnergyTol = 10.0) != rrExhausted
|
||||
|
||||
# 6. the surplus guard survives: 0.6/contact is applied to BOTH bots, so we
|
||||
# only ram an exhausted enemy while WE hold the surplus.
|
||||
check "j160: the exhaustion trigger keeps the finisher's energy-surplus " &
|
||||
"guard — an exhausted enemy while WE are lower is a ram we lose",
|
||||
ramTrigger(RamInputs(dist: 250.0, selfEnergy: 2.0, enemyEnergy: 3.0),
|
||||
enemyEnergyTol = 10.0) != rrExhausted
|
||||
|
||||
# 7. it is the finisher's own shape: the existing range guard still applies.
|
||||
check "j160: the exhaustion trigger keeps the finisher's 300px range guard " &
|
||||
"(enemy exhausted at 301px is not a ram)",
|
||||
ramTrigger(RamInputs(dist: 301.0, selfEnergy: 60.0, enemyEnergy: 3.0),
|
||||
enemyEnergyTol = 10.0) != rrExhausted
|
||||
|
||||
# 8. COMPOSITION: ramming WINS. The floor is the reserve FOR the ram, so once
|
||||
# the ram is engaged the reserve is being spent, not held. No starvation:
|
||||
# the floor alone can never make us unable to close.
|
||||
check "j160: RAMMING wins the conflict — at self energy 0.1 (below any " &
|
||||
"sane floor) an engaged ram is never floor-blocked, so the two " &
|
||||
"compose instead of deadlocking each other",
|
||||
fireFloorBlocks(F, 0.1, ramming = true) == false and
|
||||
fireFloorBlocks(F, 0.1, ramming = false) == true
|
||||
|
||||
# 9. and the floor can never be engaged at all without self energy being
|
||||
# genuinely low — the guard the owner asked for, stated as a property.
|
||||
var unsafe = 0
|
||||
for floor in [0.5, 5.0, 20.0, 25.0]:
|
||||
for e in [0.0, 1.0, 10.0, 25.0, 50.0, 100.0]:
|
||||
if fireFloorBlocks(floor, e, ramming = false) and e > floor: inc unsafe
|
||||
check "j160: the floor is a LOW-ENERGY guard only — it can never suppress " &
|
||||
"fire while we are healthy, in any configuration (" & $unsafe & ")",
|
||||
unsafe == 0
|
||||
|
||||
# 10. both knobs together: the exhausted trigger still fires while the floor
|
||||
# is at full strength, and the floor still holds when no ram is engaged.
|
||||
check "j160: both knobs ON compose — exhaustion (enemy 3, us 60) still " &
|
||||
"ram-bypasses the floor, and a non-ramming low-energy tick still holds",
|
||||
fireFloorBlocks(F, 3.0, ramming = false) and
|
||||
ramTrigger(RamInputs(dist: 100.0, selfEnergy: 60.0, enemyEnergy: 3.0),
|
||||
enemyEnergyTol = 10.0) == rrExhausted and
|
||||
not fireFloorBlocks(F, 3.0, ramming = true)
|
||||
|
||||
# ── driver ───────────────────────────────────────────────────────────────────
|
||||
|
||||
testDefaultParity()
|
||||
@@ -857,6 +1721,15 @@ when declared(loadTfilCommitEnv):
|
||||
testJ144()
|
||||
testJ145()
|
||||
testJ146()
|
||||
testJ147()
|
||||
testJ151()
|
||||
testJ152()
|
||||
testJ150()
|
||||
testJ154()
|
||||
testJ153()
|
||||
when declared(TfilRingCommitArrival):
|
||||
testJ165()
|
||||
testJ160()
|
||||
|
||||
if failures > 0:
|
||||
echo "\n", failures, " check(s) FAILED"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
## Fixture replay for the TFIL-RING mover (`movements/the_floor_is_lava_ring.nim`).
|
||||
##
|
||||
## It `include`s the mover (not `import`s it) so the replay can read the
|
||||
## private `commitTarget` / `commitTicks` — the same reason
|
||||
## `test_tfil_commit_env.nim` includes `the_floor_is_lava.nim`. Living in its
|
||||
## OWN module keeps those privates in this module's scope, so a file that
|
||||
## includes BOTH movers still compiles (there is no name clash between them:
|
||||
## this one only sees ring's).
|
||||
##
|
||||
## `--path:common_libs` relative to the repo root.
|
||||
##
|
||||
## GOLDEN GENERATION (j165 default parity). Compile this file with the golden
|
||||
## flag against the PRE-CHANGE ring, e.g. from a `git show HEAD:...` tree:
|
||||
##
|
||||
## TFIL_RING_GOLDEN_OUT=<path> \
|
||||
## nim c -r --path:. -d:tfilRingGenGolden common_libs/tests/tfil_ring_replay.nim
|
||||
##
|
||||
## Nothing here references a j165 symbol, so the SAME file generates the golden
|
||||
## on the pre-change mover and checks it on the post-change one. Regenerating
|
||||
## the golden from the new code would defeat the check — only do that after a
|
||||
## DELIBERATE change to the ring defaults.
|
||||
|
||||
import std/[os, json, random, math]
|
||||
import std/strutils except fromHex
|
||||
import gun_harness/gun_interface
|
||||
include movements/the_floor_is_lava_ring
|
||||
|
||||
const
|
||||
Seed = 20250923 ## same seed as the tfil replay: comparable arms
|
||||
ArenaW = 800.0
|
||||
ArenaH = 600.0
|
||||
fixtureRel = "tr_drussgt_vs_modularbot.jsonl"
|
||||
|
||||
const RingGoldenPath* = currentSourcePath().parentDir / "fixtures" /
|
||||
"tfil_ring_commit_default.golden"
|
||||
|
||||
type RingTickRec* = object
|
||||
spd, trn: float ## the emitted MoveCommand
|
||||
tx, ty: float ## where we are steering to
|
||||
ct: int ## ticks left on the commitment
|
||||
picked*: bool ## this tick made a NEW pick (not in the golden)
|
||||
|
||||
# Thin accessors for the mover's PRIVATE per-round state. The guard needs them
|
||||
# to measure what the commitment did; the mover's own API stays unchanged.
|
||||
proc ringPicks*(m: TFILRingModule): int = m.picks
|
||||
proc ringTarget*(m: TFILRingModule): tuple[x, y: float] = m.commitTarget
|
||||
|
||||
proc ringRecLine*(r: RingTickRec): string =
|
||||
$r.spd & " " & $r.trn & " " & $r.tx & " " & $r.ty & " " & $r.ct
|
||||
|
||||
proc loadRingStates*(): seq[WorldState] =
|
||||
let path = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
|
||||
"fixtures" / fixtureRel
|
||||
for rawLine in lines(path):
|
||||
let line = rawLine.strip()
|
||||
if line.len == 0: continue
|
||||
let n = parseJson(line)
|
||||
if n.hasKey("meta") or n.hasKey("end"): continue
|
||||
let ex = n["ex"].getFloat()
|
||||
let ey = n["ey"].getFloat()
|
||||
result.add WorldState(
|
||||
enemyX: ex, enemyY: ey,
|
||||
enemyHeading: n["eh"].getFloat(), enemySpeed: n["es"].getFloat(),
|
||||
enemyEnergy: n["ee"].getFloat(),
|
||||
selfX: n["sx"].getFloat(), selfY: n["sy"].getFloat(),
|
||||
selfHeading: n["sh"].getFloat(), selfSpeed: n["ss"].getFloat(),
|
||||
selfEnergy: n["se"].getFloat(),
|
||||
arenaWidth: ArenaW, arenaHeight: ArenaH,
|
||||
tick: n["tick"].getInt(),
|
||||
enemies: @[EnemyInfo(id: 1, x: ex, y: ey,
|
||||
heading: n["eh"].getFloat(), speed: n["es"].getFloat(),
|
||||
energy: n["ee"].getFloat())])
|
||||
|
||||
proc loadRingStarts*(): seq[int] =
|
||||
let side = currentSourcePath().parentDir.parentDir.parentDir / "tools" /
|
||||
"fixtures" / "drussgt_meta" / (fixtureRel & ".rounds.json")
|
||||
if not fileExists(side): return
|
||||
for r in parseFile(side)["rounds"]:
|
||||
result.add r["startTick"].getInt()
|
||||
|
||||
proc replayRing*(): seq[RingTickRec] =
|
||||
## Drive the REAL ring `computeMove` over the recorded WorldState stream with a
|
||||
## fixed seed, touching no env knob. With every j165 knob unset this is the
|
||||
## pre-change code path exactly.
|
||||
randomize(Seed)
|
||||
var m = initTFILRing()
|
||||
let states = loadRingStates()
|
||||
let starts = loadRingStarts()
|
||||
for i in 0..<states.len:
|
||||
if i == 0 or i in starts: m.resetRound()
|
||||
let before = m.picks
|
||||
let cmd = m.computeMove(states[i])
|
||||
result.add RingTickRec(spd: cmd.speed, trn: cmd.turnRate,
|
||||
tx: m.commitTarget.x, ty: m.commitTarget.y,
|
||||
ct: m.commitTicks, picked: m.picks != before)
|
||||
|
||||
when isMainModule and defined(tfilRingGenGolden):
|
||||
block:
|
||||
let recs = replayRing()
|
||||
let outPath = getEnv("TFIL_RING_GOLDEN_OUT", RingGoldenPath)
|
||||
var g = "# TFIL-RING default-path parity golden (j165).\n"
|
||||
g.add "# Generated from the PRE-CHANGE ring mover (`git show HEAD:...`) with\n"
|
||||
g.add "# every j165 knob UNSET, over the whole\n"
|
||||
g.add "# tools/fixtures/tr_drussgt_vs_modularbot.jsonl replay.\n"
|
||||
g.add "# Format: speed turnRate targetX targetY commitTicks\n"
|
||||
for r in recs: g.add ringRecLine(r) & "\n"
|
||||
createDir(outPath.parentDir)
|
||||
writeFile(outPath, g)
|
||||
echo "wrote ", outPath, " (", recs.len, " ticks)"
|
||||
+137
-6
@@ -47,13 +47,123 @@ misled people:**
|
||||
warning at all**.
|
||||
2. **Some flags are presence-based, not value-based.** They are read with
|
||||
`existsEnv`, so **`TR_POWER_LOG=0` turns the log ON** (any value does).
|
||||
Presence-based: `TR_POWER_LOG`, `TR_RAM_LOG`, `TR_MOVEMENT_LOG`,
|
||||
`TR_RECORD_WORLDSTATE`, `TR_RADAR_FORCE_SPIN`, `TR_RADAR_SCANLOG`,
|
||||
`TR_TRACKER_PROBE`. Value-based (`0`/`false`/`off` really disable):
|
||||
`TR_ENV_REPORT`, `TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
|
||||
Value-based (`0`/`false`/`off` really disable): `TR_ENV_REPORT`,
|
||||
`TR_TMHORIZON_LOG`, `TR_TMHORIZON_ACCURVE`,
|
||||
`TR_TMHORIZON_RESET_ON_TARGET`, `TR_POWER_POLICY`, `TR_POWER_FINISH_KILL`,
|
||||
`TR_RAM_OPPORTUNITY`, `TR_RAM_PLAN`, `GUN_SELECTOR_POOL`,
|
||||
`TR_VBULLET_ADMIT_ONLY`.
|
||||
`TR_VBULLET_ADMIT_ONLY`, `TR_LEADGAIN_LOG`, `TR_LEARNED_LOG`,
|
||||
`TR_LEARNED_GLOBAL`, `TR_LEARNED_REAL_EVENTS`, `TR_FIRE_FIX`,
|
||||
`TR_STRAFE_FIRE_FIX`, `TR_STRAFE_ESCAPE`, `TR_STRAFE_HEAT_GRID`,
|
||||
`TR_TFIL_HEAT_TIME`, `TR_TFIL_PILLAR_ON`, `TR_TFIL_DIAG`, `TR_TFIL_NO_REV`,
|
||||
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL`,
|
||||
`TR_TFIL_RING_COMMIT_ARRIVAL`, `TR_VBULLET_DEBUG`, `TR_GEO_DEBUG`,
|
||||
`TR_DEBUG_DRAW`, `TR_RESULT_LOG`.
|
||||
|
||||
### THE FULL PRESENCE-GATED LIST (j172, from `grep -rn existsEnv`)
|
||||
|
||||
The list above was **incomplete**: it was missing four knobs. The complete set,
|
||||
from `grep -rn 'existsEnv' ModularBot_garage/src common_libs | grep -v /tests/`,
|
||||
is:
|
||||
|
||||
| knob | read at | what it logs / does |
|
||||
|---|---|---|
|
||||
| `TR_POWER_LOG` | `ModularBot.nim:145` | one line per power-decision CHANGE |
|
||||
| `TR_RAM_LOG` | `ram_decision.nim:119` | one line per ram start/stop + reason |
|
||||
| `TR_MOVEMENT_LOG` | `the_floor_is_lava_ring.nim:192` | movement band / range-class changes |
|
||||
| `TR_STRAFE_LOG` | `strafe.nim:402` | one line per strafe tile pick |
|
||||
| `TR_SURF_LOG` | `wave_surfer.nim:102` | one line per wave-surfing decision |
|
||||
| `TR_FIRE_DIAG` | `ModularBot.nim:137`, `the_floor_is_lava.nim:299`, `strafe.nim:415` | per-reading fire-detection tick/raw/correction |
|
||||
| `TR_RECORD_WORLDSTATE` | `ModularBot.nim:70` | dump every observed world state |
|
||||
| `TR_RADAR_SCANLOG` | `ModularBot.nim:80` | log every radar scan tick |
|
||||
| `TR_RADAR_FORCE_SPIN` | `ModularBot.nim:79` | force the old full-360 spin radar |
|
||||
| `TR_TRACKER_PROBE` | `ModularBot.nim:86` | dump the enemy-tracker internals |
|
||||
|
||||
**For these ten, `NAME=0` turns the feature ON.** OFF means the line is ABSENT.
|
||||
That is why `.env.example` shows every one of them commented out: there is no
|
||||
"off" spelling, only absence. To disable one, delete its line.
|
||||
|
||||
Two more are read with `existsEnv` but are *not* features — `TR_ENV_FILE` (an
|
||||
empty value is the correct "use the default" spelling) and the loader's own
|
||||
`existsEnv(e.key)` conflict check.
|
||||
|
||||
**And one label is misleading:** `env_report.nim` prints `TR_TFIL_DIAG`,
|
||||
`TR_TFIL_HOLD_WHEN_TRAPPED`, `TR_TFIL_COMMIT_ARRIVAL` and
|
||||
`TR_TFIL_RING_COMMIT_ARRIVAL` through `sourceOfPresence`, but all four are read
|
||||
**by value** (`getEnvBool`) in the source. The value is always right; only the
|
||||
`(source: ...)` label is affected. Do not read that label as "presence-gated".
|
||||
|
||||
---
|
||||
|
||||
## ONE EXPERIMENT, END TO END (mirrored from `.env.example`)
|
||||
|
||||
Pick ONE knob. Here it is `TR_TFIL_ARRIVE_TICKS`; the shape is the same for
|
||||
every knob.
|
||||
|
||||
```sh
|
||||
# 1. write the arm as its own file — that is how you GUARANTEE the arm, because
|
||||
# nothing else can be applied on top of it
|
||||
cat > /tmp/arm_arrive15.env <<'EOF'
|
||||
TR_MOVEMENT=tfil
|
||||
TR_TFIL_ARRIVE_TICKS=15.0
|
||||
EOF
|
||||
|
||||
# 2. RESTART THE BOT. Env is read ONCE, at boot (module init). Editing .env
|
||||
# while the bot runs changes nothing; there is no live reload.
|
||||
cd ModularBot_garage && ./ModularBot.sh # or restart the GUI
|
||||
|
||||
# 3. CONFIRM IT TOOK EFFECT, before reading a single result line.
|
||||
# `source: .env` = your file was applied. `source: default` = it was not.
|
||||
grep '^\[env\]' /tmp/modularbot_stdout.log | grep -E 'env file|ARRIVE_TICKS'
|
||||
# [env] env file: /tmp/arm_arrive15.env (source: TR_ENV_FILE)
|
||||
# [env] TR_TFIL_ARRIVE_TICKS = 15.0 (source: .env)
|
||||
|
||||
# 4. point at the file instead of copying it into .env:
|
||||
TR_ENV_FILE=/tmp/arm_arrive15.env ./out/ModularBot
|
||||
./out/ModularBot --env-file /tmp/arm_arrive15.env
|
||||
# A file you ASKED for and that does not exist stops the bot with an error; a
|
||||
# missing default .env is silent. This is what an A/B run does: one frozen
|
||||
# binary, one env file per arm.
|
||||
|
||||
# 5. what is switched on at all:
|
||||
grep '^\[modules\]' /tmp/modularbot_stdout.log
|
||||
```
|
||||
|
||||
## SAFE TO EXPERIMENT WITH RIGHT NOW
|
||||
|
||||
The honest list is SHORT: after the recent campaign most experimental knobs are
|
||||
either never live-tested or already measured null/harmful, and `.env.example`
|
||||
says so on every one of them. These four are safe in the sense that they either
|
||||
cannot change a decision, or are the ones a measurement actually supports.
|
||||
|
||||
| knob | what changes | what to watch | a good result |
|
||||
|---|---|---|---|
|
||||
| `TR_GEO_DEBUG=on` | draw-only geometry overlay | the circles on the two tanks, each heading line | you can SEE the tile the picker chose; it cannot change a decision |
|
||||
| `TR_VBULLET_DEBUG=1` + `TR_VBULLET_DEBUG_GUN=all` | draw-only: each admitted gun's virtual bullets | travelled path, aim ring, miss vector | you can see the signal the selector ranks on; also draw-only |
|
||||
| `TR_TFIL_DIAG=on` | fills the per-pick loss histogram (tfil only) | the tfil pick log line | `sReach/sCool/sSafe/sCand` tell you where tiles are lost; provably moves no command |
|
||||
| `TR_MOVEMENT=tfil` | runs the long-shipped mover | nothing to compare against | you are reproducing an older, documented behaviour; only do it together with the `TR_TFIL_*` knobs |
|
||||
|
||||
## ALREADY REJECTED OR MEASURED NULL — WITH THE NUMBER
|
||||
|
||||
Do not re-run these by accident.
|
||||
|
||||
| knob / arm | result | where |
|
||||
|---|---|---|
|
||||
| `TR_TFIL_GEO_MODE=both-rej` + `TR_TFIL_GEO_TAU=60` | **REJECTED** live, 420 battles, 15 opponents: damage/run **-8.83**, p(sign-flip) **0.0061**, Wilcoxon p 0.011. Round wins null. Offline it did what was predicted (arrivals 4.5% -> 29.4%) and that is why it is bad: +26 px distance on 15/15, less damage. | `docs/tfil_geo_ab.md` |
|
||||
| `TR_RAM_FLOOR_ENERGY=5` | **CLEAN NULL**: **-0.018 wins/run**, p(sign-flip) **0.7676**, under a **0.1420 wins/run** MDE, 900 battles. Mechanism fired on 0.04% of ticks (~200x less than the offline ruler said). Do not re-test: more runs buy resolution on an effect that is not there. | `docs/ram_floor_exhaustion_ab.md` |
|
||||
| `TR_RAM_FLOOR_ENERGY=10/20` | measured COSTLY offline (20 blocked 24.7% of all ticks) and the zone it guards is nearly empty: only 4.8% of shots are taken below 10 energy. | same |
|
||||
| `TR_TMHORIZON_WINDOW=150` | **MEASURED HARMFUL** live: 26.5% round wins vs 49.0% for the shipped rack, p = 0.036. Keep 0. | env_reference "Measured verdicts" |
|
||||
| `TR_POWER_POLICY=0` | **MEASURED HARMFUL** live: real hit rate 10.61% -> 7.88%, p = 0.0012. | same |
|
||||
| `TR_TFIL_HEAT_TIME=1` | **MEASURED HARMFUL** live at every tau tried (3/5/9/15); tau15 alone is -22 damage/run, p = 0.046. | `docs/tfil_heat_pillar_ab.md` |
|
||||
| `TR_MOVEMENT=tfil_ring` | **MEASURED**: round wins 16/49 -> 6/49, p = 0.012. Best live hit rate of anything measured, half the survival. | same / env_reference |
|
||||
| the full `TR_RACK_*` rack | **MEASURED NEGATIVE VALUE**: Pattern ALONE beats the full 13-gun rack, p = 0.0012. Adding guns costs rounds. | `docs/gun_rack_analysis.md` |
|
||||
| `TR_TFIL_TURN_BIAS=9` + `_TURN_REF_DEG=0` | **LIVE NULL**: +0.15 wins/run, p(sign) 0.244, under a 0.30 MDE, 300 battles. | `docs/movement_campaign.md` (j145) |
|
||||
| `TR_RAM_OPPORTUNITY=on` | **MEASURED not to convert**: 0/59 opportunity -> contact. The finisher ram is the only path that converts, and it is always on. | env_reference |
|
||||
| `TR_TFIL_PILLAR_ON=1` | live-tested, and the recommendation to revert to pillar-on was **OVERRULED by the owner**: the contrast is inside the MDE (33 damage/run, 1.22 wins/run at n=10). Pillar stays removed. | `docs/tfil_heat_pillar_ab.md` |
|
||||
|
||||
> **A null is only a null at the resolution that run reached.** The frozen
|
||||
> 15-opponent panel at 14 runs/arm resolves ~0.17 wins/run and ~7.65 damage/run;
|
||||
> the j163 run resolved 0.1420 wins/run. "Clean null" here means *no effect at or
|
||||
> above that size* — not *no effect*.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,6 +336,8 @@ shooting *look* like missing).
|
||||
| `TR_POWER_POLICY` | `1` | `0` = uncapped control arm (today's behaviour without the energy policy) |
|
||||
| `TR_POWER_LOG` | off | **presence-based**: if the var exists at all (even `=0`) log each power decision |
|
||||
| `TR_RAM_LOG` | off | **presence-based**: log ram on/off with the reason |
|
||||
| `TR_RAM_FLOOR_ENERGY` | `0.0` | j160 firing floor: at/below this self energy we start no NEW shot, holding a ram reserve. `0` = off. `~5` = one p=1.0 return hit + two 0.1 shots. Bypassed while ramming |
|
||||
| `TR_RAM_ENEMY_ENERGY` | `0.0` | j160 exhaustion trigger: last-scanned enemy energy `<=` this -> ram mode. `0` = off. Keeps the finisher's energy-surplus and 300px guards |
|
||||
| `TR_MOVEMENT_LOG` | off | **presence-based**: log movement band/class changes |
|
||||
| `TR_TMHORIZON_LOG` | off | value-based: `1` = let the horizon TM gun log its thinking per shot |
|
||||
| `TR_ENV_REPORT` | `1` | print the boot-time `[env]` report to stdout; `0` suppresses it |
|
||||
@@ -268,6 +380,7 @@ name, with no new knob:
|
||||
| `TR_RACK_<GUN>` = `both`/`1v1`/`melee` | that gun may be selected | `TR_RACK_<GUN>=off`: the gun is removed from the rack |
|
||||
| `TR_POWER_POLICY` | energy-aware power caps (default) | uncapped: the gun's own preferred power |
|
||||
| `TR_FIRE_FIX` | the corrected enemy-fire detector (default) | the shipped `prev - energy` detector |
|
||||
| `TR_FIRE_LAG` = `<int>` | back-date every detected enemy fire by N ticks at spawn (0 = shipped; **1 = the measured live detection lag**, j147) | n/a — it is a value knob |
|
||||
| `TR_RADAR_FORCE_SPIN` | force the old stateless full-spin melee radar (**off by default**) | the adaptive arc-narrowing radar (default) |
|
||||
| `TR_TFIL_HEAT_TIME` | time-indexed bullet heat (**off by default**) | flat, time-independent heat (default) |
|
||||
| `TR_VBULLET_DEBUG` | draw the virtual-bullet overlay (**off by default**) | nothing drawn |
|
||||
@@ -416,8 +529,10 @@ actually removed, so **overkill scores nothing**. Damage is `4p` (p≤1) / `6p-2
|
||||
| `TR_TFIL_RANGE_K` | `60` | softness of the falloff outside the band |
|
||||
| `TR_TFIL_CORRIDOR_HEAT` | `10.0` | heat added along a bullet's corridor to the wall |
|
||||
| `TR_TFIL_WALL_HOTNESS` | `15.0` | peak wall radiance |
|
||||
| `TR_TFIL_RING_COMMIT_ARRIVAL` | off | **presence/value**: `on` = hold the committed dodge tile until we are actually ON it, instead of the fixed 5-tick dwell. Default path byte-identical. **NEVER LIVE-TESTED** |
|
||||
| `TR_TFIL_RING_NOREV_SPEED` | `0.0` | px/tick. Below this self speed a mid-flight target switch may not turn the bot around; `0.0` = off (the pre-knob behaviour). **NEVER LIVE-TESTED** |
|
||||
|
||||
Defaults read in `the_floor_is_lava_ring.nim:116-133`.
|
||||
Defaults read in `the_floor_is_lava_ring.nim:188-196` and `:173-175`.
|
||||
**Discrepancy to be aware of:** that file's header comment still says
|
||||
`CORRIDOR_HEAT default 5.0` / `WALL_HOTNESS default 10.0` (the pre-retune values);
|
||||
the **code defaults are `10.0` / `15.0`** (commit `7f6ccfb`). The code is the
|
||||
@@ -501,6 +616,7 @@ Measured byte-identical on `bmPath`. Kept for experiments; leave at defaults.
|
||||
| `TR_RADAR_SCAN_LOG_PATH` | `/tmp/radar_scan_log.jsonl` | where that goes |
|
||||
| `TR_TRACKER_PROBE` | off | presence-based; per-tick enemy tracker vs server enemy count |
|
||||
| `TR_TRACKER_PROBE_PATH` | `/tmp/tracker_probe.jsonl` | where that goes |
|
||||
| `TR_CAPTURE_AIM` | off | presence-based; append `aim_scan` / `aim_fire` records — what the lead model BELIEVED (gun id, blst, age, boff, aim, turret, terr, heat, ax/ay, tof) to the same capture file as `TR_RECORD_WORLDSTATE` (needs `TR_RECORD_WORLDSTATE=1`) |
|
||||
| `TR_VBULLET_DEBUG` | off | presence-based; overlay the virtual bullets in the GUI debug graphics (see below) |
|
||||
| `TR_VBULLET_DEBUG_GUN` | selected gun | `all`/`*` for every gun, or a gun name (e.g. `Pattern`); unset = only the currently selected gun |
|
||||
| `TR_VBULLET_DEBUG_MAX` | `32` | cap on bullets drawn per tick |
|
||||
@@ -518,6 +634,21 @@ selector's training signal visible. Turn it on with:
|
||||
- colour per gun is the SAME table as the turret (`vbullet_draw.gunColors`), with
|
||||
a one-line legend in the top-left corner.
|
||||
|
||||
### Known limitations — `TR_CAPTURE_AIM` (j177)
|
||||
|
||||
**`aim_fire` only records shots that PASSED `setFire`.** The capture is written
|
||||
from the bot's own fire call, so a shot the **server rejected or that the bot
|
||||
never issued** produces no `aim_fire` record at all.
|
||||
|
||||
That is a real blind spot: from these records you can never answer *why* a shot
|
||||
did not happen — e.g. the gun was still hot, or the turret was not yet aligned.
|
||||
A missing `aim_fire` is ambiguous between "no target / didn't try" and "tried and
|
||||
was refused". The `aim_scan` records carry the belief state (age, boff, turret,
|
||||
heat) for every scan, so you can often *infer* the cause by looking at the scans
|
||||
that precede the gap, but the capture does not state it. Read the gaps as
|
||||
"no recorded shot", never as "the server blocked it". Closing this needs a
|
||||
pre-`setFire` gate record, which is j177+ work, not present today.
|
||||
|
||||
The **adaptive-melee radar** has no env knobs. Its tuning lives in compile-time
|
||||
constants in `radars/adaptive_melee_radar.nim:36-50`: `MaxRadarTurnRate=45`,
|
||||
`FreshnessTicks=16`, `FreshStreakTicks=3`, `MarginDeg=20`,
|
||||
|
||||
@@ -3454,3 +3454,218 @@ middle 30.4% · corr10 32.1% · bullets 65.0% · nofield 3.9%.
|
||||
**The shipped default is untouched.** `TR_MOVEMENT=strafe` remains the default;
|
||||
`TR_TFIL_BULLET_CORE` / `TR_TFIL_BULLET_AURA` default to today's `10.0` / `5.0`,
|
||||
so `TR_MOVEMENT=tfil` still means today's tfil, byte-for-byte (guard check 1).
|
||||
|
||||
# Batch 8 — the fire-detection lag (j147)
|
||||
|
||||
*Pre-registered BEFORE any battle of this batch was launched. No battle of this
|
||||
batch existed when this section was written; the frozen binary for it is the
|
||||
commit that adds `TR_FIRE_LAG` and the `TR_FIRE_DIAG` ghost-spawn trace.*
|
||||
|
||||
## The owner's report, and what was measured
|
||||
|
||||
*"i don't know if is the drawing only the arrives 1 tick later in the gui, but
|
||||
the bullet auras looks like are all 1 tick-ish behind the real bullet!"*
|
||||
|
||||
The first job was to answer **drawing or decision**, not to fix anything. Three
|
||||
measurements, in order, each one able to stop the next:
|
||||
|
||||
### 1. The corpus says the ENERGY DROP is on the fire's own row (lag 0)
|
||||
|
||||
`/tmp/tfil_ab2/out` (70 battles, `runN.jsonl` + `runN.events.jsonl`): for every
|
||||
true fire event, the row at which the shooter's energy drop becomes visible is
|
||||
`fireTick - 1` for **702/702** self fires in round 1 and 100% over the corpus —
|
||||
i.e. in the recorded frame the drop and the shot are the SAME instant (a bullet
|
||||
takes its first step during the turn it is fired, MEASURED: 1293/1293 `hitwall`
|
||||
events have their first out-of-bounds bullet position at step
|
||||
`hitwallTick - fireTick + 1`, which is only consistent with a first step inside
|
||||
the firing turn). So the corpus alone cannot see a lag: it has no view of WHEN
|
||||
our scan runs relative to the dispatch.
|
||||
|
||||
### 2. The corpus is NOT the bot's view, so the lag had to be measured LIVE
|
||||
|
||||
`common_libs/tests/measure_fire_ghost_lag.py`. The bot logs one
|
||||
`[firediag] SPAWN tick=… sx=… sy=… gx=… gy=… p=… eta=…` line per detected fire
|
||||
(the ghost's DRAWN position and our own position, the timeline anchor). The
|
||||
capture supplies the true fire events (origin, direction, power) and the rounds.
|
||||
The timeline is anchored without guessing: `[firediag] EV hit tick=… getTurn=…`
|
||||
lines vs. the sidecar's own event turns match exactly, and give
|
||||
`getTurn = bot.tick + 1` (j134, re-verified) — so a ghost logged at bot tick `t`
|
||||
was placed during server turn `t + 1`.
|
||||
|
||||
| arm | matched spawns | detection lag | ghost-vs-observer px (mean / median / p90) | arrival-deadline error (ticks, mean / median) |
|
||||
|---|---:|---|---:|---:|
|
||||
| tfil, lag 0 | 413 | **+1 tick, 100%** | **19.06 / 19.16 / 22.00** | **0.987 / 0.991** |
|
||||
| tfil, `TR_FIRE_LAG=1` | 446 | +1 tick, 100% | **5.37 / 5.65 / 8.96** | **0.063 / 0.051** |
|
||||
| strafe, lag 0 | 497 | +1 tick (77.9%; the rest are duplicate/split waves of a fire already counted) | **16.08 / 18.23 / 21.81** | 0.771 / 0.944 |
|
||||
| strafe, `TR_FIRE_LAG=1` | 466 | +1 tick, 100% | **6.01 / 5.91 / 9.80** | **0.065 / 0.051** |
|
||||
|
||||
**The answer to the owner: it is NOT only the drawing — the decision is late.**
|
||||
The aura is displaced by exactly **one whole bullet step (11..20 px, 19.1 px
|
||||
mean for tfil)**, in the direction of travel, and the arrival deadline the
|
||||
mover reads is **a full tick late (0.99 ticks)**. The mechanism is measured, not
|
||||
guessed: the server dispatches a turn's fire **after** our `go()` for that turn,
|
||||
so the energy drop of a turn-`T` shot first reaches our scan at turn `T+1`; and
|
||||
because a bullet takes its first step during the turn it is fired, the true
|
||||
bullet is already one step downrange when we see it. Both movers place the ghost
|
||||
at the SCANNED enemy position — where the bullet was *born* — and then advance
|
||||
it once per tick, so the entire ghost trajectory is the true one shifted one
|
||||
turn later, for the bullet's whole life.
|
||||
|
||||
**It is OURS.** The draw/advance order was checked and is correct (both movers
|
||||
`advanceBullets()` -> `detectFires()` -> build the field, i.e. a ghost spawned
|
||||
this tick is drawn at its age-0 position and every older ghost has been advanced
|
||||
exactly once: build-then-advance, which is the correct direction; an
|
||||
advance-then-build order would have shown the aura one tick AHEAD). With
|
||||
`TR_FIRE_LAG=1` the ghosts land on the observer's bullet to within the enemy's
|
||||
own scan staleness (5.4 px mean, max 8 px = the enemy's top speed), which is the
|
||||
floor this design can reach: the origin is the enemy's *scanned* position, not
|
||||
its fire-time position.
|
||||
|
||||
### The treatment
|
||||
|
||||
`TR_FIRE_LAG` (int, **default 0 = today's behaviour byte-for-byte**, `x` is only
|
||||
touched when `lag > 0`), read once in the shared
|
||||
`common_libs/movement_harness/fire_tracker.nim` and applied by BOTH movers at
|
||||
spawn: `x = origin + dir * speed * lag`, `y = …`. The arrival deadline needs no
|
||||
separate change — every mover derives it from the ghost's own position
|
||||
(`heatDecay(along / speed)`, the `dot < 0` reap), so a correct position gives a
|
||||
correct deadline. Guard: `test_tfil_commit_env.nim` 77 -> **87 checks**, all pass
|
||||
(default parity on the golden replay, exact n-step back-date, deadline shortens
|
||||
by exactly `lag`, junk/negative degrade to 0, the ghost is reaped exactly one
|
||||
tick earlier).
|
||||
|
||||
## Arms (frozen, `tools/ab/arms_fire_lag.txt`)
|
||||
|
||||
| # | arm | mover | `TR_FIRE_LAG` | what it isolates |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `tfil_off` | tfil | 0 (default) | **the reference** — today's tfil |
|
||||
| 2 | `tfil_lag1` | tfil | 1 | the back-date, on tfil |
|
||||
| 3 | `strafe_off` | strafe | 0 (default) | **the reference** — today's strafe |
|
||||
| 4 | `strafe_lag1` | strafe | 1 | the back-date, on strafe |
|
||||
|
||||
Panel: the FROZEN 15-opponent `tools/ab/panel_movement.txt`. Harness:
|
||||
`tools/ab/tournament_run.sh` + `tournament_analyze.py`.
|
||||
|
||||
## Pre-registered prediction, MDE and decision rule
|
||||
|
||||
* **MDE, stated up front.** The verdict layer is the paired per-opponent
|
||||
difference over 15 opponents, exactly as batches 4-7. Batch 7 (5 runs/arm)
|
||||
measured **MDE = 12.8 damage/run and 0.28 wins/run**; this batch runs **3
|
||||
runs/arm**, so by `sqrt(5/3)` the MDE degrades to roughly **16 damage/run and
|
||||
0.36 wins/run** — and the incoming-hit-rate MDE to roughly **1.9 points**.
|
||||
**Any true effect smaller than that is invisible here by construction, and a
|
||||
null will be recorded as "not distinguishable", never as "no effect".**
|
||||
* **Prediction.** `tfil_lag1` > `tfil_off` and `strafe_lag1` > `strafe_off` on
|
||||
damage/run and round wins, because the field the mover decides on is displaced
|
||||
by a whole bullet step today and stops being after the fix. The **mechanism is
|
||||
the incoming hit rate** (the dodge should survive strictly more), and the
|
||||
offline gate already measured the mechanism geometrically (19.1 -> 5.4 px,
|
||||
0.99 -> 0.06 ticks), so a mechanism-positive / outcome-null result is the
|
||||
EXPECTED shape given the MDE, and is recorded as such — the same verdict
|
||||
pattern as j144, j145 and j146.
|
||||
* **Verdict rule (unchanged, not re-interpreted afterwards).** The cross-opponent
|
||||
sign test p < 0.05 on one primary metric (damage/run or round wins) with the
|
||||
other not down, SD/SE/95% CI/MDE reported.
|
||||
* **Nothing separates -> nothing changes.** `TR_FIRE_LAG` stays default 0 and
|
||||
the shipped movers are untouched. A mechanism-positive outcome-null does NOT
|
||||
retract the geometric measurement, and does NOT change `TR_MOVEMENT=strafe`.
|
||||
|
||||
*(results appended below after the battles)*
|
||||
|
||||
### MEASURED — gate B: the guard (`test_tfil_commit_env.nim`, 77 -> 87 checks)
|
||||
|
||||
All 87 pass, including the byte-for-byte golden replay of the shipped mover with
|
||||
`TR_FIRE_LAG` unset (check 1). The j147 ones:
|
||||
|
||||
* `TR_FIRE_LAG` unset -> `FireLag 0`, and the ghost lands EXACTLY on the scanned
|
||||
enemy (`b.x == ei.x` bit for bit — the position is only touched when `lag > 0`).
|
||||
* `=1` -> the ghost is exactly one bullet step (17 px at power 1.0) downrange on
|
||||
its own heading; `=2` -> exactly two; the step length is the true
|
||||
`20 - 3*power`, not a scaled one.
|
||||
* **the arrival deadline**: the mover's eta equals the TRUE remaining flight
|
||||
(10.764706 vs 10.764706) where the lag-0 eta was 11.764706 — a full tick late;
|
||||
at `lag=2` the deadline shortens by exactly 2 ticks.
|
||||
* a junk or negative value degrades to the shipped lag 0 (never a negative
|
||||
back-date); clearing the knob restores the shipped spawn exactly.
|
||||
* end to end: the ghost is reaped (`dot < 0`, the geometric arrival the mover
|
||||
actually uses) **exactly one tick earlier** — 12 -> 11 ticks.
|
||||
* `test_env_report` + `test_env_dotenv` green with `TR_FIRE_LAG` registered in
|
||||
`env_report.nim` + `knownEnvNames()` + `.env.example` + `docs/env_reference.md`.
|
||||
|
||||
### MEASURED — gate C: the live A/B, 180 battles
|
||||
|
||||
> **Provenance.** Session `/tmp/ab/j147_firelag`, frozen binary `d21f7ce`
|
||||
> (sha256 `29571d4d…`), panel `tools/ab/panel_movement.txt` (15 opponents,
|
||||
> FROZEN), arms file `tools/ab/arms_fire_lag.txt` registered above BEFORE any of
|
||||
> these battles ran. **4 arms x 15 opponents x 3 runs x 3 rounds = 180 battles,
|
||||
> 0 failed, 0 never started, 473 s.** The MDEs the analyzer actually reported at
|
||||
> 3 runs/arm: **12.2-15.1 damage/run, 0.33-0.55 wins/run, 1.4-2.9 hit-rate
|
||||
> points** — the pre-registered estimate (~16 / ~0.36 / ~1.9) was right.
|
||||
|
||||
**Pooled dashboard (descriptive, NOT the verdict):**
|
||||
|
||||
| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | incoming hit rate | mean distance |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `tfil_off` | 45 | 109.7 | 187.8 | 1.24 | 56/135 | 41.5% | 16.91% | 394 |
|
||||
| `tfil_lag1` | 45 | 111.8 | 192.9 | 1.20 | 54/135 | 40.0% | 17.45% | 396 |
|
||||
| `strafe_off` | 45 | 106.3 | 160.1 | 1.44 | 65/135 | 48.1% | 12.84% | 434 |
|
||||
| `strafe_lag1` | 45 | 110.1 | 159.0 | **1.62** | **73/135** | **54.1%** | 13.21% | 428 |
|
||||
|
||||
**Verdict layer, each mover against ITS OWN reference (the only comparison that
|
||||
isolates the knob):**
|
||||
|
||||
| arm | metric | mean Δ | 95% CI | sign test | p(sign) | p(sign-flip) | Wilcoxon p | MDE |
|
||||
|---|---|---:|---|---:|---:|---:|---:|---:|
|
||||
| `tfil_lag1` vs `tfil_off` | damage | +2.08 | [-7.22, +11.38] | 6/15 | 0.6072 | 0.6375 | 0.9773 | 12.15 |
|
||||
| `tfil_lag1` vs `tfil_off` | wins | -0.04 | [-0.29, +0.21] | 4/9 | 1 | 0.8516 | 0.5923 | 0.33 |
|
||||
| `tfil_lag1` vs `tfil_off` | hit_rate | +0.94 | [-0.93, +2.81] | 10/15 | 0.3018 | 0.2984 | 0.222 | 2.45 |
|
||||
| `strafe_lag1` vs `strafe_off` | damage | +3.77 | [-6.98, +14.53] | 9/15 | 0.6072 | 0.4598 | 0.6701 | 14.04 |
|
||||
| `strafe_lag1` vs `strafe_off` | wins | +0.18 | [-0.13, +0.49] | 5/9 | 1 | 0.3359 | 0.1723 | 0.41 |
|
||||
| `strafe_lag1` vs `strafe_off` | hit_rate | +0.33 | [-0.73, +1.39] | 9/15 | 0.6072 | 0.5403 | 0.5509 | 1.38 |
|
||||
|
||||
### VERDICT — plain
|
||||
|
||||
1. **Was it only the drawing? NO. The decision was late, by exactly one bullet
|
||||
step, and the fix is now in.** Measured live on 1777 matched ghost spawns
|
||||
across both movers: the detection lag is **+1 tick on 100%** of them, the
|
||||
ghost-vs-observer displacement is **19.1 px mean / 22.0 p90** (tfil) and
|
||||
**16.1 / 21.8** (strafe), and the arrival deadline the mover reads is
|
||||
**0.99 / 0.77 ticks late**. With `TR_FIRE_LAG=1` the displacement is
|
||||
**5.4 / 9.0 px** and the deadline error **0.06 ticks** — the residue is the
|
||||
ENEMY's own scan staleness (<= 8 px, its top speed), which is the floor this
|
||||
design can reach because the ghost's origin is the enemy's *scanned* position.
|
||||
The draw/advance order was checked and is correct, so the GUI was faithfully
|
||||
drawing a wrong field.
|
||||
2. **The live OUTCOME is null, and that is recorded as "not distinguishable".**
|
||||
`tfil_lag1` is -0.04 wins/run and `strafe_lag1` is +0.18 wins/run — both far
|
||||
under the MDEs the analyzer reported (0.33 and 0.41). Nothing reaches the
|
||||
pre-registered bar, so under the campaign's rule **nothing is changed**:
|
||||
`TR_FIRE_LAG` stays **default 0** and both movers ship exactly as before. The
|
||||
knob is there, measured and documented, for anyone who wants the arm.
|
||||
3. **The live MECHANISM did not move either** — incoming hit rate +0.94 pp (tfil)
|
||||
and +0.33 pp (strafe), neither significant. This is the fourth consecutive
|
||||
movement job where a real, measured mechanism change does not show up as fewer
|
||||
hits taken. Two readings, both worth keeping: the dodge is limited by the
|
||||
1-tick-stale enemy POSITION and by the 8-px scan staleness of the ghost's
|
||||
origin, not by a 19-px translation of a field whose core is 18 px and whose
|
||||
corridor is 40 px wide; and at 3 runs/arm a real few-percent effect in hit
|
||||
rate sits under the ~1.4-point MDE. **What the fix does buy, provably, is
|
||||
the arrival deadline**: every mover's heat, corridor and reap are now timed
|
||||
off the bullet's real position, which is the input the next arrival-commit /
|
||||
time-indexed-heat work needs to be correct at all.
|
||||
4. **The one significant live result in this batch is the MOVER, not the knob**:
|
||||
`strafe_lag1` vs `tfil_off` is +0.38 wins/run (sign 10/12, p = 0.0386) with
|
||||
the incoming hit rate **-4.67 pp (sign 2/15, p = 0.0074, sign-flip
|
||||
p = 0.0007, Wilcoxon p = 0.0024)** and mean distance +34 px (14/15). That is
|
||||
the known strafe-over-tfil gap reproducing itself, and it is exactly why the
|
||||
pre-registration demanded the within-mover reference: read against `tfil_off`
|
||||
alone, the knob looks like a winner it is not.
|
||||
|
||||
**j148 — corridor LENGTH bound (unmeasured).** `TR_TFIL_CORRIDOR_TICKS` and
|
||||
`TR_STRAFE_CORRIDOR_TICKS` (both default `0`) bound the corridor — the rotated
|
||||
rectangle from the ghost bullet along its heading — by `min(distance to the wall,
|
||||
bulletSpeed * TICKS)`, so a fast (low-power) bullet's corridor is long and a
|
||||
slow one's is short, instead of every bullet blanketing the arena to the wall.
|
||||
`0` is exactly today's behaviour; only the LENGTH changes, the heat inside the
|
||||
surviving corridor is untouched. **Untested** — no battle, no measurement, the
|
||||
parity guard only says the default path is byte-for-byte unchanged.
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
# j162 — the FIRING FLOOR: exhaustion measurement + a re-sized A/B proposal
|
||||
|
||||
**No battle, server, GUI or A/B was started for this job.** Both knobs remain
|
||||
default-`0.0`; `out/ModularBot` was not rebuilt. Everything below the divider is
|
||||
a proposal awaiting the owner's explicit permission.
|
||||
|
||||
---
|
||||
|
||||
## MEASURED (`common_libs/tests/measure_ram_exhaustion`, offline, state only)
|
||||
|
||||
Same corpus as j160: **8149 closed-loop recordings / 35163 rounds / 34.46M
|
||||
ticks**. No counterfactual replay (the offline harness scored 0/6 on
|
||||
closed-loop questions, `docs/offline_harness_trust.md`).
|
||||
|
||||
### 1. We die BROKE, and it is a death event — not a state we sit disabled in
|
||||
|
||||
* **21865 rounds (61.2%) end with self energy crossing 0**; 99.0% of rounds end
|
||||
in *some* death. Energy on the last tick we were alive:
|
||||
**median 0.83, mean 2.50, p90 8.90, max 24.83**.
|
||||
55.9% of self-deaths at <=1, 84.2% at <=5, 92.8% at <=10, **100% at <=20**.
|
||||
* Time at energy <= 0 before the round ends: **median 1 tick** (p90 18). The
|
||||
round ends on the crossing tick. The 1.72%-of-ticks figure is dominated by a
|
||||
handful of recordings that hold a dead bot for hundreds of ticks — a recorder
|
||||
artefact, not a lived state. **There is no recoverable disabled window to
|
||||
defend.**
|
||||
* The reserve that *would* have absorbed the killing blow (the overshoot of the
|
||||
final hit): **median 0.40, p75 2.00, p90 6.90, p99 15.0**. A free 5-energy
|
||||
reserve would have saved 85.1% of self-deaths.
|
||||
|
||||
So the prompt's hypothesis ("it dies at 40 energy, so the floor protects
|
||||
against nothing") is **false**: the bot dies with nothing, every time. The
|
||||
floor's premise is real.
|
||||
|
||||
### 2. We cannot climb back out of a low-energy dip
|
||||
|
||||
Energy rises on 0.598% of tick-pairs (~5.87 landed hits/round; **mean landed
|
||||
power 1.42**, mode 1.0 — not 0.1). Per tick spent at a given level:
|
||||
|
||||
| energy | climb next tick | killed this tick | ratio |
|
||||
|---|---|---|---|
|
||||
| <= 3 | 0.130% | 0.830% | dying **6.4x** more likely |
|
||||
| <= 5 | 0.232% | 0.663% | dying **2.9x** more likely |
|
||||
| <= 10 | 0.365% | 0.437% | dying 1.2x more likely |
|
||||
| <= 20 | 0.500% | 0.256% | recovering **2x** more likely |
|
||||
|
||||
Below ~10 energy a landed hit is not coming; below 20 it usually is. **A floor
|
||||
at 20 would therefore block the only zone where recovery is actually
|
||||
plausible.**
|
||||
|
||||
### 3. What the floor costs
|
||||
|
||||
| floor | % ticks blocked | rounds | med run | p90 run | mean run | % runs ending in death | bank @1.0p |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 3 | 7.64% | 23086 | 34 | 299 | 98 | 80.8% | 8.2 |
|
||||
| 5 | **9.58%** | 23739 | 53 | 330 | 118 | 78.0% | **9.9** |
|
||||
| 10 | 14.52% | 25211 | 89 | 433 | 162 | 70.3% | 13.5 |
|
||||
| 20 | 24.74% | 27799 | 139 | 621 | 236 | 60.0% | 19.6 |
|
||||
|
||||
"Bank" = mean suppressed run x `p/(10+2p)` energy/tick, the gun-heat ceiling
|
||||
(`heat = 1 + p/5`, cool 0.1/tick). At 0.1 power it is 0.52 energy for floor 5;
|
||||
at 2.0 power, 16.9.
|
||||
|
||||
**Measured caveat, and it matters:** the recorded energy ledger closes *exactly*
|
||||
— `start + landed-gains - damage - end = -0.00` over 35065 rounds. **These
|
||||
captures do not charge the firepower cost**, so the cost column is derived from
|
||||
the game rules, not read off the data. The landed-hit *power* distribution is
|
||||
read off the data (mode 1.0, mean 1.42) and is what sets the bracket.
|
||||
|
||||
### 4. Honest read — materially DIFFERENT from the geometry arm
|
||||
|
||||
Firing is **net energy-negative** for this bot on this panel: a landed hit
|
||||
returns `3p` for `p` spent (break-even hit rate 1/3), and the hit rate cannot
|
||||
exceed 5.87 hits / 76 shots-per-round heat ceiling = **7.7%**. So not firing
|
||||
really does bank energy — about 9.9 at floor 5.
|
||||
|
||||
That is the same *kind* of trade the geometry arm made — spend offence, buy
|
||||
protection — but a different *magnitude*:
|
||||
|
||||
* **Safety claim is stronger.** The geometry arm's safety gain did not convert
|
||||
into wins. Here the hazard is measured directly: 0.66%/tick death at energy
|
||||
<= 5, against a p75 overshoot of 2.0 and a bank of 9.9. The bank is above
|
||||
p75 and near p90 — a genuinely material reserve, not a rounding error.
|
||||
* **Damage cost is ~an order of magnitude smaller.** Floor 5 suppresses ~4.4
|
||||
shots per median run; at 4 damage/hit and a 7.7% hit rate that is ~1.4
|
||||
damage per suppressed run, ~2 damage/run. The geometry arm lost **8.83
|
||||
damage/run** for its unconverted gain.
|
||||
* **It is a light touch in time, not in behaviour**: 9.6% of ticks, median run
|
||||
53 ticks. Not a blackout.
|
||||
|
||||
**Verdict: the floor is worth an A/B. It is not the clean negative.** But the
|
||||
honest counterweight is on the record: 78% of suppressed runs still end in
|
||||
death, and the bank is only reached *because* we stopped shooting.
|
||||
|
||||
**Chosen value: `TR_RAM_FLOOR_ENERGY=5`** — bank 9.9 (above p75 overshoot 2.0,
|
||||
near p90 6.9) at 7.6%-vs-9.6% less tick cost than 10. Floor 20 is dropped on
|
||||
the measurement: it costs 24.7% of ticks and sits on top of the <= 20 recovery
|
||||
window.
|
||||
|
||||
---
|
||||
|
||||
## PROPOSAL — PRE-REGISTERED, **NOT RUN**
|
||||
|
||||
* Harness: `tools/ab/tournament_run.sh` + `tools/ab/tournament_analyze.py`,
|
||||
unmodified. Panel: `tools/ab/panel_movement.txt` (frozen 15-opponent movement
|
||||
panel). Unit of evidence is the opponent, not the battle. One frozen binary
|
||||
from `git archive` of `j160-ramfloor`.
|
||||
* **Contamination control — j159's three guarantees, unchanged**: (1) per-arm
|
||||
`TR_ENV_FILE` in this job's own outdir (`/tmp/j162_floor/env/<arm>.env`);
|
||||
(2) the per-run botdir holds only `.json`, `.sh` and a symlink to the frozen
|
||||
binary — no `.env`, and the loader does not walk up; (3) **every** run's
|
||||
`[env]` boot report is checked against its arm, and any disagreement voids
|
||||
the session. **A session-record check runs BEFORE analysis**, not as a rewrite
|
||||
afterwards (j159 had a mid-analysis `session.json` rewrite; not repeated here).
|
||||
|
||||
| arm | env | role |
|
||||
|---|---|---|
|
||||
| `A_off` | both unset | REFERENCE |
|
||||
| `B_floor` | `TR_RAM_FLOOR_ENERGY=5` | the floor alone (value from the measurement) |
|
||||
| `C_exhaust` | `TR_RAM_ENEMY_ENERGY=20` | the exhaustion trigger alone |
|
||||
| `D_both` | `TR_RAM_FLOOR_ENERGY=5 TR_RAM_ENEMY_ENERGY=20` | the combined policy |
|
||||
|
||||
No arm is inert, so none is dropped: `C_exhaust=20` acts on 7.1% of ticks
|
||||
(enemy <= 20 while we are > 20) and `D_both` is the only arm that answers the
|
||||
composition rule. A floor *sweep* arm is deliberately omitted — the measurement
|
||||
chose the value, and the budget is better spent on n.
|
||||
|
||||
### Size — corrected for the real throughput
|
||||
|
||||
j159 measured **420 battles in 1110 s = 23 battles/min** (not the ~7/min the
|
||||
previous estimate assumed). MDE scales as `1/sqrt(n)`; the shipped default
|
||||
movement gate resolved **0.17 wins/run at 210 runs/arm**. For a target MDE of
|
||||
**0.10 wins/run**: `n = 210 * (0.17/0.10)^2 = 607` runs/arm, rounded up to
|
||||
**42 runs/opponent = 630 runs/arm**.
|
||||
|
||||
* 15 opponents x 4 arms x 42 runs x 3 rounds = **3780 battles ≈ 2.7 h**.
|
||||
* Decision-only 2-arm version (`A_off` vs `B_floor`): 15 x 2 x 42 x 3 =
|
||||
**1890 battles ≈ 1.4 h**, still at MDE 0.10.
|
||||
|
||||
### Metrics (fixed now)
|
||||
|
||||
**Primaries: damage/run, round-win rate.** Mechanism, never a verdict: self
|
||||
energy at death, ticks spent disabled, shots fired/run, ram-kill count.
|
||||
Paired per-opponent deltas, mean/SD/SE/95% CI, sign test, sign-flip
|
||||
permutation, Wilcoxon cross-check, reported MDE. Two-sided.
|
||||
|
||||
### Verdict rule (fixed now)
|
||||
|
||||
Adopt only if BOTH primaries favour the arm with `p(sign-flip) < 0.05` **and**
|
||||
the effect is at or above the reported MDE. Otherwise do not ship; both knobs
|
||||
stay `0.0`. A clean null is a fully acceptable result. No subsetting, no
|
||||
dropping opponents, no re-running to chase a p-value.
|
||||
|
||||
---
|
||||
|
||||
# j163 — PRE-REGISTRATION: the FIRING FLOOR A/B (2 arms), BEFORE ANY BATTLE
|
||||
|
||||
**Written and committed before a single battle of this design was run.** Nothing
|
||||
below was chosen after seeing data. Worktree `j160-ramfloor` @ `64e23e2`, one
|
||||
frozen binary built by `tournament_run.sh` from `git archive HEAD`, both knobs
|
||||
default-`0.0`, no code changed by this job.
|
||||
|
||||
## Hypothesis
|
||||
|
||||
The bot dies broke: **61.2% of rounds (21865/35753) end with self energy
|
||||
crossing 0**, and energy on the last alive tick is median 0.83. Below **5**
|
||||
energy the next tick brings death **2.9x** more often than a landed hit
|
||||
(0.663%/tick vs 0.232%/tick); the bank of a suppressed run at floor 5 is ~9.9
|
||||
energy against a p75 overshoot of 2.0 / p90 6.9 — a free 5-energy reserve would
|
||||
have saved 85.1% of self-deaths. Firing is net energy-negative here (a landed
|
||||
hit returns `3p` for `p` spent; the hit rate is capped at 5.87/76 = 7.7%), so
|
||||
holding a reserve in the sub-5 zone should convert safety into **round wins**.
|
||||
|
||||
## Arms — two, differing in exactly one variable (`TR_MOVEMENT=tfil` pinned)
|
||||
|
||||
| arm | per-arm env file | role |
|
||||
|---|---|---|
|
||||
| `A_baseline` | `TR_RAM_FLOOR_ENERGY=0` | REFERENCE (today's shipped behaviour) |
|
||||
| `B_floor5` | `TR_RAM_FLOOR_ENERGY=5` | treatment, the value chosen by the j162 measurement |
|
||||
|
||||
**The `TR_RAM_ENEMY_ENERGY` (ram-exhaustion) arm is DELIBERATELY EXCLUDED.**
|
||||
Its own measurement found the trigger is rare at its literal threshold and that
|
||||
whether it fires is close to a coin flip in direction — it is not a
|
||||
well-founded mechanism. Excluding it here is a decision, **not an oversight**;
|
||||
this job tests only the one well-founded mechanism. If the floor is adopted, the
|
||||
exhaustion trigger needs its own design and its own A/B.
|
||||
|
||||
## Primaries (fixed now)
|
||||
|
||||
1. **round-win rate** (rounds won / rounds fought) — **the deciding primary**
|
||||
2. **damage/run**
|
||||
|
||||
## THE DAMAGE MDE IS STATED UP FRONT, BECAUSE IT IS BIGGER THAN THE EFFECT
|
||||
|
||||
Expected damage cost of floor 5: **~2 damage/run** (4.4 suppressed shots per
|
||||
median run x 4 damage/hit x 7.7% hit rate) — versus **8.83 damage/run** for the
|
||||
already-rejected geometry arm. The design's **damage MDE is 7.65**. The damage
|
||||
effect is therefore **~3.8x below what this design can resolve**.
|
||||
|
||||
> **Recorded before any data: we EXPECT TO BE UNABLE TO MEASURE THE DAMAGE
|
||||
> COST DIRECTLY. A null on damage/run is the predicted outcome, not a surprise,
|
||||
> and must NOT be re-read after the fact as evidence either for or against the
|
||||
> floor.** The verdict is judged on **ROUND WINS**. The damage MDE is a
|
||||
> one-sided blind spot of this design, fixed in advance.
|
||||
|
||||
## Counterweight (also on the record before any data)
|
||||
|
||||
**78.0% of suppressed runs still end in death.** The floor protects the tail of
|
||||
the energy ledger; it is not a shield. A mechanism-positive / outcome-null
|
||||
result is the fifth such in this campaign (j144, j145, j146, j147, j159).
|
||||
|
||||
## Mechanism metrics (reported, never a verdict)
|
||||
|
||||
* self energy at death (per round);
|
||||
* share of rounds ending at self energy **<= 0** (baseline **61.2%**);
|
||||
* shots/run;
|
||||
* share of ticks with firing suppressed (**floor 5 predicts ~9.6% of ticks,
|
||||
median suppressed run ~53 ticks**).
|
||||
* Reported **per opponent as well as pooled**: j159's re-analysis showed a
|
||||
pooled test hid a real per-opponent effect (safety signal p=0.0008
|
||||
per-opponent, null pooled). The unit of evidence is the opponent.
|
||||
|
||||
## Size, MDE and the time floor
|
||||
|
||||
15 frozen opponents x 2 arms x **42 runs** x 3 rounds = **1890 battles**.
|
||||
MDE ~**0.10 wins/run** (`210 x (0.17/0.10)^2`; 210 was the design that resolved
|
||||
0.17 wins/run). At the measured 22.7-23.2 runs/min that is **~1.4 h — a floor
|
||||
on elapsed time, not an estimate**: opponent heterogeneity does not average
|
||||
down with added runs. If time runs short, the achieved n and the MDE actually
|
||||
reached are reported exactly; the panel and the arms are **not** silently
|
||||
shrunk.
|
||||
|
||||
## Contamination controls (all three, in order)
|
||||
|
||||
1. Each arm is launched with `TR_ENV_FILE` pointing at a **per-arm file this
|
||||
job generated** in its own directory (`/tmp/j163_env/<arm>.env`) — never a
|
||||
shell export, because the dotenv loader gives the FILE priority. The file
|
||||
dir is deliberately **outside** `--outdir` (`tournament_run.sh` `rm -rf`s
|
||||
the outdir). This matters: the owner has an 18 KB `.env` at
|
||||
`ModularBot_garage/out/.env` in the main tree. The per-run botdir holds only
|
||||
`.json`, `.sh` and a symlink to the frozen binary; the frozen binary's own
|
||||
directory holds no `.env`; the loader's fallbacks are `./.env` then
|
||||
exe-adjacent with **no parent walk**, so the owner's file is unreachable.
|
||||
2. **Every run's `[env]` boot block is verified against its arm as runs
|
||||
complete** — `TR_RAM_FLOOR_ENERGY` and `TR_MOVEMENT=tfil` — and `mis-set`
|
||||
is counted and reported. A previous session was invalidated-risk because
|
||||
this was checked too late.
|
||||
3. The session record is **read before analysis, never rewritten**. j159 had a
|
||||
mid-analysis `session.json` rewrite; declaring `TR_MOVEMENT` explicitly
|
||||
disables the analyzer's leaked-`TR_MOVEMENT` fatal check, so the built-in
|
||||
leak guard is **not trustworthy here** — the explicit per-run `[env]`
|
||||
verification above is the primary control. If the guard misbehaves it is
|
||||
**reported as a finding, not worked around**.
|
||||
|
||||
## Verdict rule (fixed now, two-sided)
|
||||
|
||||
**Adopt** only if round-win rate favours `B_floor5` with a per-opponent
|
||||
**sign-flip permutation p < 0.05** AND the effect is at or above the reported
|
||||
MDE. Otherwise **do not ship**; `TR_RAM_FLOOR_ENERGY` stays `0.0`. No
|
||||
subsetting, no dropping opponents, no re-running to chase a p-value, no
|
||||
reinterpreting the bar after seeing the data. **A clean null is a fully
|
||||
acceptable result** — and a null here licenses only "no effect >= MDE is
|
||||
detectable at this design", never "the knob is harmless".
|
||||
|
||||
---
|
||||
|
||||
## MEASURED
|
||||
|
||||
*(appended after the battles — everything above was committed first, at
|
||||
`6cfb169`)*
|
||||
|
||||
### MEASURED — the live A/B, 450 runs/arm, 2700 rounds (j163)
|
||||
|
||||
* **Provenance.** Frozen 15-opponent movement panel
|
||||
(`tools/ab/panel_movement.txt`), 15 x 2 x **30 runs** x 3 rounds =
|
||||
**450 runs/arm, 2700 rounds**, `conc=6`, **0 failed, 0 never started**.
|
||||
Frozen binary `d9a39c3b8472…`, `TR_MOVEMENT=tfil` in both arms; the only
|
||||
difference is `TR_RAM_FLOOR_ENERGY` `0` (`A_floor0`, reference) vs `5`
|
||||
(`B_floor5`).
|
||||
* **Env verification — 0 mis-set.** Every run's `[env]` boot block was checked
|
||||
against its arm as the session progressed, live at **24 / 193 / 410 / 826 /
|
||||
900** runs completed: no disagreement at any checkpoint. Per-arm
|
||||
`TR_ENV_FILE` in the session's own directory, botdir without a `.env`, loader
|
||||
does not walk up parents. This is contamination control #2 from the
|
||||
pre-registration, satisfied.
|
||||
* **DEVIATION FROM THE PRE-REGISTRATION, DISCLOSED.** The design asked for
|
||||
**42 runs/opponent** (1890 battles, MDE ~0.10 wins/run). Measured throughput
|
||||
was **14-22 runs/min**, not the assumed 22.7-23.2, so the wall-clock cost of
|
||||
the pre-registered n was not affordable. As the pre-registration required,
|
||||
the **panel and the arms were NOT shrunk**: the full 15 opponents and both
|
||||
arms were kept and the **runs per opponent were reduced to 30**. The MDE
|
||||
actually reached is reported below and is the honest resolution limit of this
|
||||
run. No opponent was dropped, no arm was re-run to chase a p-value.
|
||||
|
||||
**Pooled dashboard (descriptive, NOT the verdict):**
|
||||
|
||||
| arm | runs | dmg/run | wins/run | round wins | round win rate |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `A_floor0` | 450 | 113.65 | 0.200 | — | **40.30%** |
|
||||
| `B_floor5` | 450 | 112.70 | 0.182 | — | **39.70%** |
|
||||
|
||||
**Verdict layer** (per-opponent paired deltas, arm − reference; the sign-flip
|
||||
is the exact 2^15 permutation the pre-registration names as the decision test):
|
||||
|
||||
| metric | mean Δ | 95% CI | p(sign-flip) | sign test | Wilcoxon p | **MDE reached** |
|
||||
|---|---:|---|---:|---:|---:|---:|
|
||||
| **round-win rate** | **-0.59 pp** | [-3.90, +2.72] | **0.7676** | 1.00 | 0.84 | **4.73 pp** |
|
||||
| wins/run | -0.0178 | [-0.117, +0.082] | 0.7676 | 1.00 | 0.84 | 0.1420 |
|
||||
| damage/run | -0.95 | [-3.88, +1.98] | 0.5298 | 1.00 | 0.84 | 4.19 |
|
||||
|
||||
### HEADLINE FINDING — the mechanism barely fired; the offline energy corpus did not survive contact with the live game
|
||||
|
||||
1. **Suppression was 0.04% of ticks, not the 9.6% the offline ruler predicted** —
|
||||
a **~200x** smaller effect. The pre-registration's own mechanism metric
|
||||
(`share of ticks with firing suppressed`, predicted ~9.6%, median suppressed
|
||||
run ~53 ticks) is the number that failed, and it failed by two orders of
|
||||
magnitude.
|
||||
2. **Only 4.8% of shots are ever taken in the low-energy zone, and the floor
|
||||
removed 14% of those.** The gate therefore touches a small slice of a small
|
||||
slice: a bot that almost never wants to fire at low energy. The pre-
|
||||
registration's expected damage cost (~2 damage/run) was the arithmetic
|
||||
consequence of the 9.6% figure; with 0.04% it is ~200x smaller still, which
|
||||
is why the damage MDE (4.19) is unreachable by construction and not by bad
|
||||
luck.
|
||||
3. **Rounds ending at self energy <= 0: 40.6% live vs 61.2% implied by the
|
||||
offline corpus** (the j162 baseline the pre-registration quoted). The
|
||||
recorded-fixture corpus over-states how often we die broke by ~1.5x. This is
|
||||
the second, independent way the same corpus mis-called the live game.
|
||||
4. **Median self energy at death 14.1 -> 15.5** — the floor moved the death
|
||||
energy by +1.4, real but tiny, and nowhere near the "we sit disabled" state
|
||||
the floor was built for.
|
||||
5. **The pre-registered blind spot was called correctly.** The pre-registration
|
||||
states, in advance, that we expect to be unable to measure the damage cost
|
||||
directly and that a damage null must not be re-read afterwards as evidence.
|
||||
That call was right, and it is the reason this run cannot be misread.
|
||||
|
||||
### VERDICT — DO NOT ADOPT
|
||||
|
||||
1. **The pre-registered verdict rule is not met.** Adopt required round-win
|
||||
rate favouring `B_floor5` with per-opponent sign-flip p < 0.05 AND the
|
||||
effect at or above the reported MDE. Observed: **-0.59 pp against**, p =
|
||||
**0.7676**, under the 4.73 pp MDE. Round wins and wins/run are the same
|
||||
null (p = 0.7676, MDE 0.1420 wins/run).
|
||||
2. **`TR_RAM_FLOOR_ENERGY` stays `0.0`.** Do not adopt, do not ship, and **do
|
||||
not re-test this knob.** The design that could resolve a real effect does
|
||||
not exist at an affordable run count, and the mechanism it was built to
|
||||
suppress is nearly absent in the live game. Re-running buys resolution on an
|
||||
effect that is not there.
|
||||
3. **A null here does NOT prove the knob inert — the opposite.** The mechanism
|
||||
fired on 0.04% of ticks. This run licenses only: "no effect >= 4.73 pp of
|
||||
round-win rate at 450 runs/arm". It says nothing about the ~0.04% of ticks
|
||||
it did suppress, because too few of them existed to measure.
|
||||
4. **The generalisable finding is the corpus, not the knob.** The offline
|
||||
energy corpus over-predicted both the size of the low-energy firing window
|
||||
(9.6% -> 0.04%) and the rate of dying broke (61.2% -> 40.6%). An offline
|
||||
ruler built on recorded fixtures is only as representative as the fixtures;
|
||||
the death-energy corpus does not represent the live energy ledger. Future
|
||||
offline rulers for exhaustion must be calibrated against a live
|
||||
death-energy distribution before their predictions are pre-registered as
|
||||
expectations, not just as a rationale.
|
||||
|
||||
**Ship state: unchanged. `TR_RAM_FLOOR_ENERGY=0.0` and `TR_RAM_ENEMY_ENERGY=0.0`
|
||||
remain the shipped defaults, and the ram path keeps its pre-j160 behaviour.**
|
||||
This is the sixth mechanism-positive-or-presumed / outcome-not-positive result
|
||||
in the campaign (j144, j145, j146, j147, j159, j163) — and the first where the
|
||||
mechanism was not merely ineffective but **~200x smaller than the offline ruler
|
||||
said it would be**.
|
||||
@@ -0,0 +1,283 @@
|
||||
# Range vs approach: melee is unavailable against DrussGT, so the gun at range is the only lever
|
||||
|
||||
**Date:** 2026-09-27 · **Job:** j167 · **Branch:** `research/lead-targeting`
|
||||
**Evidence base:** j166 (`worktrees/j166-aim` @ `a5a49bd`), j165 (`fe77056`), j159
|
||||
(`4a1f3e1`), j165/j151/j152/j154, j160/j163 (`0df7763` / `51bfa57`), j161
|
||||
(`docs/ram_floor_exhaustion_ab.md:219`).
|
||||
**Instrument for the new numbers below:** `worktrees/j167-ceiling/j167_probe.py`
|
||||
(branch `j167-ceiling`) — pure replay of the recorded corpora
|
||||
(`/tmp/tfil_ab2/out/`, 60 252 of our own scored shots over 140 battles; and
|
||||
`/tmp/firelag_live2/`, 1 700 shots / 1 664 incoming bullets over 4 battles).
|
||||
**No battle, A/B, server or GUI was run for this document.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The ceiling
|
||||
|
||||
**Melee is structurally unavailable against DrussGT, and the exhaust/ram line is a
|
||||
niche rather than a lever.** The evidence is two-sided and independent: (a) *our
|
||||
mover's own ruler* — 94% of forced (no-safe-tile) picks happen at range > 300 u
|
||||
and only 6-7% of picks reach the chosen tile at the estimated arrival time, with
|
||||
the destination hot on arrival 35-42% of the time; and (b) *the j166 pursuit
|
||||
probe* — 35 windows × 250 ticks of open-loop kinematics in which **every**
|
||||
steering law is equal-or-worse than doing nothing clever:
|
||||
|
||||
| steering law (j166) | closing (u/tick) | contact % | TTI (ticks) |
|
||||
|---|---:|---:|---:|
|
||||
| current-position closing | 4.03 | 65.7 | 72.3 |
|
||||
| body/barrel ray | 1.38 | 40.0 | 131.4 |
|
||||
| velocity intercept (degenerate at equal speed) | — | 0 over 2 118 ticks | — |
|
||||
| best case: lag-5 lead | 4.20 | 65.7 | 68.9 |
|
||||
|
||||
The root cause of the historical **0/59 proactive-ram** result
|
||||
(`docs/ramming_negative_result.md`) is not a bad gate: **DrussGT never let the
|
||||
distance drop.** Per-round minimum distance 152-338 u, median ~490 u, and
|
||||
`frac(dist < 50) = 0.000` in all four recorded rounds. A pursuit that never gets
|
||||
below 152 u cannot make contact, whatever the gate says. It is also not a
|
||||
gun-side problem: **the server never transmits the enemy's gun direction**
|
||||
(`ScannedBotEvent` = `energy, x, y, direction` where `direction` is the BODY
|
||||
heading, plus `speed`; `TurnProcessor.kt:313-323`). There is no aim-based lead,
|
||||
no aim-based dodge and no early warning available. Against DrussGT the
|
||||
body-to-bullet angle has median **90.1 deg**, and the body ray passes within
|
||||
10 deg of us on **0.0% of 1 794 ticks** — its gun is always on us, its body
|
||||
never is.
|
||||
|
||||
**Recorded so the idea is not re-proposed:** the j166 lag-5 residue does improve
|
||||
TTI (72.3 → 68.9) and **converts to contact 0% of the time**. A 4% TTI gain with
|
||||
zero contact conversion is noise, not a lead.
|
||||
|
||||
### The honest remaining niches for exhaust/ram
|
||||
|
||||
1. **An opponent that closes on us.** Ram works whenever the other side comes to
|
||||
us. Nothing here generalises away from that.
|
||||
2. **A late-round exhaustion when they are already near.** The one conversion
|
||||
ever recorded came from a *finisher* (enemy 16 → 1 energy), which is already
|
||||
the default gate.
|
||||
3. **Any 2v1+ mode**, where closing dynamics are not symmetric.
|
||||
|
||||
Against DrussGT specifically none of these will move the score, and
|
||||
`TR_RAM_FLOOR_ENERGY` is under test in j163 — do not duplicate it.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the ceiling implies
|
||||
|
||||
**If range is held, the only remaining lever is the gun at range, and the binding
|
||||
numbers are the gun's, not the tile picker's.** The long-range hit rate is
|
||||
**~9-10%** (Pattern live: 12.3% at 300-450 px, 9.2% at 450+; overall 10.5% —
|
||||
`docs/headon_longrange_live.md`), the live hit half-window at 450 px is
|
||||
**`atan(18/450) = 2.29°`** (`docs/gun_campaign.md:59`), and the measured arrival
|
||||
aim error is **16.19° mean-abs at 450+** (`docs/bitbrain_campaign.md:107`,
|
||||
`docs/headon_longrange_live.md:85`). 16.19° is **7× the window**. The tile picker
|
||||
cannot close a 7× gap that sits downstream of the gun.
|
||||
|
||||
### New measurement — arrival aim error decomposed (j167, 23 275 shots at 450+ px)
|
||||
|
||||
Arrival aim error is defined non-circularly: the angle between the fired bearing
|
||||
and the bearing to where the target *actually is* when the bullet arrives
|
||||
(`tof = 20 - 3·power`, so the flight time comes from the power, not from the
|
||||
shot's own geometry). It splits **exactly**, as signed angles, into
|
||||
|
||||
* **B, the model part** = the error the gun's own lead model leaves behind, and
|
||||
* **C, manoeuvre** = the target's path curvature relative to the
|
||||
constant-velocity extrapolation from the true state at fire time.
|
||||
|
||||
| band (px) | n | mean|A| | mean|B| (model) | mean|C| (manoeuvre) | sd(B) | sd(C) | corr(B,C) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0-100 | 24 767 | 83.36 | 101.56 | 49.38 | 124.5 | 75.9 | −0.65 |
|
||||
| 300-450 | 11 372 | 13.07 | 21.77 | 11.00 | 26.0 | 13.0 | −0.87 |
|
||||
| **450+** | **23 275** | **11.26** | **17.18** | **7.73** | **20.7** | **9.3** | **−0.85** |
|
||||
|
||||
At 450+ the model part's variance is **2.2× the manoeuvre part's**, and
|
||||
`corr(B,C) = −0.85` means the two largely *cancel* — the net 11.26° is much
|
||||
smaller than either part. **The 16° is a lead-model number, not a dodge number.**
|
||||
Two supporting numbers: a naive constant-velocity extrapolation of a **2-tick-old**
|
||||
position scores 8.45° mean-abs at 450+, and the time-of-flight implied by the
|
||||
shot's own geometry (holding the current velocity) sits a **median 10 ticks short**
|
||||
of the power-derived arrival tick (p10 −18, p90 +31) — i.e. the gun systematically
|
||||
**under-leads in time**, consistent with `docs/lead_capture_by_range.md`
|
||||
(capture 0.135 at 450+).
|
||||
|
||||
> **Do not read "a stale-CV model scores 8.45°" as "simplify the gun".** This is
|
||||
> exactly the offline-ruler trap that killed HeadOn: the ruler said a no-lead gun
|
||||
> was equal-or-better at 300+ and live it hit **20×/23× less**
|
||||
> (`docs/headon_longrange_live.md`). The corpus is closed-loop — the target's
|
||||
> manoeuvre is a *reaction to our own bullet* — so (B) and (C) are not separable
|
||||
> here, and per `docs/offline_harness_trust.md` (j89: 0/6 on closed-loop) this
|
||||
> instrument ranks per-gun single-tick prediction, it does not predict a live A/B.
|
||||
|
||||
### Cross-reference: what is still open in the gun docs
|
||||
|
||||
| doc | finding | status after this ceiling |
|
||||
|---|---|---|
|
||||
| `docs/gun_campaign.md:59` | hit half-window 2.29° at 450 px; measured signal 4.6-7.6° | **STILL OPEN and now the load-bearing number.** The decomposition says the gap is in the *model*, and the model is systematically 10 ticks short in time-of-flight. |
|
||||
| `docs/gun_campaign.md:40-45` | lead amplitude is dead (1.0/1.5/2.0/3.0 all worse); radial knobs are bearing-invariant by construction | **CLOSED.** |
|
||||
| `docs/gun_campaign.md:737-753` | `len6` +0.49 wins/run (p=0.039, n=15) did not replicate on n=33 | **CLOSED.** |
|
||||
| `docs/bitbrain_campaign.md:189` | BitBrain / TMHorizon corrector adds no measurable aim (16.199 vs 16.193) | **CLOSED.** |
|
||||
| `docs/bitbrain_campaign.md:107` | Pattern's own lead correlation with the required lead is 0.165 at 450+ | **STILL OPEN.** It is the same defect the decomposition names. |
|
||||
| `docs/state_window_gate.md` | single wave-relative state at Q=4 predicts the miss bin at 0.4094 vs 0.2348 majority, but bins are 4.58-7.63° wide | **STILL OPEN, and now the best-placed surviving idea** — it is a *model* correction, which is where the error is. |
|
||||
| `docs/gun_rack_analysis.md:423-455` | the 16-candidate rack ranking A/B found no winner; knobs added, all neutral | **CLOSED** (13 guns, `onlyPattern` shipped). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Negative-results ledger — mechanisms closed by measurement
|
||||
|
||||
Do not re-litigate any row. The unit of evidence is the **opponent**.
|
||||
|
||||
| mechanism | knob / job | headline number | verdict |
|
||||
|---|---|---|---|
|
||||
| Geometry-weighted tile draw | `TR_TFIL_GEO_MODE/TAU`, j152 `38fbc6e`, A/B'd j159 `4a1f3e1` | **−8.83 damage/run, p=0.0061**; wins −0.05, p=0.46; +26.3 px mean distance on 15/15 opponents | **REJECTED.** Default off, stays off. |
|
||||
| The bounded hold | `TR_TFIL_HOLD_MAX_TICKS`, j154 `2223ca6` | mechanism-positive, outcome-null (j146/j153) | **Default off.** No live win. |
|
||||
| The proactive ram | `oldram` vs `base` gate `dist<200` | **p=0.69**, damage 279 vs 284, survival 17/49 vs 16/49; **0/59 opportunity→contact** | **CLOSED** (`docs/ramming_negative_result.md`). |
|
||||
| The aim-based ram | j166 `a5a49bd` | body ray within 10° of us on **0.0% of 1 794 ticks**; body/barrel ray contact 40.0% vs 65.7% for doing nothing clever | **IMPOSSIBLE** — the server never sends gun direction (`TurnProcessor.kt:313-323`). |
|
||||
| Arrival commitment (`tfil`) | j144 `d2005ab` | mechanism-positive, outcome-null | Default off. |
|
||||
| Turn-cost tiebreak among safe tiles | j145 `39c90fd` | real but small mechanism, under-powered outcome null (300 battles, 5 arms) | Default off. |
|
||||
| Field shape (safety) | j146 `de5d02b` | safe-set broken 63.5% → 30.4% offline; live null on damage and wins (375 battles, 5 arms) | **Default off.** |
|
||||
| Corridor bound | j148 `5e213df` `TR_{TFIL,STRAFE}_CORRIDOR_TICKS` | never landed in a live A/B | Untested, not a candidate. |
|
||||
| Ring arrival commitment | j165 `fe77056` `TR_TFIL_RING_COMMIT_ARRIVAL` | reach 0.24% → **3.05%**, picks 5 521 → 525, byte-for-byte default parity over 20 026 ticks, 148 guards | **Mechanism-positive, default off.** The strongest surviving movement mechanism. |
|
||||
| Firing floor / enemy-exhaustion ram | j160 `23bce2d`, A/B'd j163 `51bfa57` | **clean negative**; the offline energy corpus missed the live game by 200× | **Under test in j163 — do not duplicate.** |
|
||||
| Fire-detection lag | j147 `d21f7ce` `TR_FIRE_LAG` | displacement 19.06 → 5.37 px, deadline error 0.99 → 0.06 ticks; **live outcome-neutral**; ceiling ~10% of incoming damage (measured below) | **Default off, permanently.** |
|
||||
| Hard arrival bound | j151 `a01141c` `TR_TFIL_ARRIVE_TICKS` | mechanism-positive, outcome-null | Default off. |
|
||||
|
||||
> **Methodological caution (j161), binding on everything above.** Pooled tests
|
||||
> can hide real per-opponent effects: j159's safety signal was **p=0.0008
|
||||
> per-opponent while the pooled test was null** (`docs/ram_floor_exhaustion_ab.md:219`).
|
||||
> **Any future mechanism claim must report per-opponent mechanism metrics, not a
|
||||
> pooled mean.** A pooled null is not evidence of absence; it is evidence that
|
||||
> the heterogeneity was not averaged down.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lead-time lever 1 — what a 2-tick-stale ghost really costs
|
||||
|
||||
`TR_FIRE_LAG` back-dates the bullet ghost (default 0). Energy-drop shot
|
||||
detection lags **1.9 ticks mean**; median bullet flight is **19 ticks**
|
||||
(`onHitByBullet` gives 82 hits / 7 421 ticks, one update per ~90 ticks).
|
||||
|
||||
**Measured on 55 750 incoming bullets** (`/tmp/tfil_ab2/out/`). For each bullet:
|
||||
the time to closest approach of the target's recorded path to the bullet line
|
||||
(**median 9 ticks**, p10 1, p90 39), and the minimum number of ticks of lead time
|
||||
a max-speed hard-turn dodge needs to build 17 px of lateral displacement:
|
||||
|
||||
| minimum dodge lead time (ticks) | 0 | 1 | 2 | 3 | 4 | 5+ |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| share of incoming bullets | **57%** | 33% | 4% | 2% | 1% | 2% |
|
||||
|
||||
**57% of incoming bullets are already undodgeable at the instant they are fired**,
|
||||
and only **~10%** (need ≥ 2 ticks) are in a regime where a 2-tick detection lag
|
||||
can change anything. Applying the lag to the open-loop dodge model:
|
||||
|
||||
| ghost lag (ticks) | modelled hits | Δ vs perfect | share of all bullets whose hit/miss verdict flips |
|
||||
|---|---:|---:|---:|
|
||||
| 0 | 22 419 | — | — |
|
||||
| **1.9 / 2** | **25 100** | **+2 681 (+12.0%)** | **10.18%** |
|
||||
| 3 | 26 230 | +14.6% | 14.63% |
|
||||
| 5 | 28 949 | +22.0% | 22.04% |
|
||||
|
||||
**Verdict: the 1.9-tick lag costs on the order of 10% more incoming hits** — at
|
||||
the measured ~200 damage/run, roughly **20 damage/run**, an order of magnitude
|
||||
below the movement A/B damage MDE. This is consistent with `TR_FIRE_LAG`'s already
|
||||
measured live outcome-neutral result. The ghost is *wrong*, but wrongness at
|
||||
10% of incoming damage cannot be turned into wins at this sample size.
|
||||
|
||||
**Recommendation: `TR_FIRE_LAG` stays off permanently.** It is a correctness fix
|
||||
with a measured, bounded, sub-MDE payoff.
|
||||
|
||||
---
|
||||
|
||||
## 5. Lead-time lever 2 — the 16° decomposed, component by component
|
||||
|
||||
At 450+ px (23 275 shots), against the 11.26° net arrival error:
|
||||
|
||||
| component | measured | addressable? |
|
||||
|---|---|---|
|
||||
| **(a) enemy body-gun decoupling** | `\|gun dir − body heading\|` median **89.9°** (p10 25.9, p90 154.0, n=60 928). Extrapolating the target along its **gun** instead of its **body** would put the arrival bearing **79.5° median** wrong. | **Not present, and not addressable.** The intercept model uses the target's *recorded position and velocity*, both of which are the true body quantities and both exactly observed. Body-gun decoupling therefore contributes **exactly 0** to our arrival error. It is fatal for *aim-based* leading and threat warning (j166) and irrelevant to *position-based* leading. |
|
||||
| **(b) our own leading model** | mean|·| **17.18°**, sd **20.7**; implied time-of-flight a **median 10 ticks short** of the power-derived arrival tick | **DOMINANT, and addressable.** This is ~2.2× the manoeuvre variance and it is the whole of the 16°. |
|
||||
| **(c) target manoeuvre between scan and fire** | mean|·| **7.73°**, sd **9.3** | Small relative to (b), and **irreducible** — it is the dodger's own unpredictability, exactly the ~half of the under-lead `docs/lead_capture_by_range.md` attributes to a trivial predictor's own ceiling. |
|
||||
| **(d) gun turn rate / time-to-fire** | the correct solution drifts a **median 0.416°/tick** (p90 5.45). The gun turns at 10°/tick, so a 17° correction takes **1.7 ticks ≈ 0.40°** of drift. | **Not binding.** Contributes ~**0.4°, i.e. ~3% of the 11.26° error.** The gun can always reach the answer; it aims at the wrong answer. |
|
||||
|
||||
**So the 16° is not (a), not (c) and not (d). It is (b) — the lead model's
|
||||
time-of-flight, short by ~10 ticks.** Caveat, stated once and load-bearing: on a
|
||||
closed-loop corpus (B) and (C) are not cleanly separable, since the target's
|
||||
manoeuvre is a reaction to our own shot; the `corr(B,C) = −0.85` is exactly that
|
||||
confound showing up. The *rank order* (b) ≫ (c) ≫ (d) > (a)=0 is robust to it
|
||||
because (b) and (c) differ by 2.2× in variance and (d) is 3%.
|
||||
|
||||
---
|
||||
|
||||
## 6. The proposed lever: pre-multiply before learning — PREMISE DEAD
|
||||
|
||||
The design: aim error is largely a *product* (bearing-rate × time-of-flight), so
|
||||
pre-multiply the two features and feed one small Tsetlin machine. **Measured on
|
||||
the same corpus, the premise does not hold and the experiment should not be
|
||||
built.** `y` = the required lead angle (current bearing → arrival bearing), i.e.
|
||||
exactly the quantity the gun must predict; `b` = the observable 4-tick finite
|
||||
difference of the bearing; `t = 20 − 3·power`.
|
||||
|
||||
| band (px) | n | corr(**b·t**, y) | corr(b+t, y) | R² additive [1,b,t] | R² product [1,b·t] | held-out side acc, additive | held-out side acc, product | held-out residual rms (deg) |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 0-200 | 24 934 | −0.1020 | −0.1022 | 0.0105 | 0.0104 | 0.579 | 0.580 | 75.98 |
|
||||
| 200-300 | 671 | 0.1190 | 0.0766 | 0.0147 | 0.0142 | 0.488 | 0.487 | 13.70 |
|
||||
| 300-450 | 11 372 | **0.1501** | 0.0299 | 0.0256 | 0.0225 | 0.544 | 0.534 | 8.43 |
|
||||
| **450+** | **23 275** | **0.2833** | 0.1052 | **0.0831** | 0.0803 | **0.603** | **0.601** | **6.68** |
|
||||
| pooled | 60 252 | −0.1005 | −0.1009 | 0.0102 | 0.0101 | — | — | — |
|
||||
|
||||
*(side accuracy is 2-fold held-out and balanced; the TM record is ~0.47-0.49)*
|
||||
|
||||
Two things are true and the second kills the idea:
|
||||
|
||||
1. **As a single scalar, the product is much the better feature at range**:
|
||||
`corr(b·t, y) = 0.283` vs `corr(b+t, y) = 0.105` at 450+ — 2.7× better, and
|
||||
5× better at 300-450. So the *premise* ("the error is a product, not a sum")
|
||||
is **confirmed as a statement about correlation**.
|
||||
2. **But it buys nothing a weight-sum cannot already express.** The best linear
|
||||
additive model on the same two features reaches **R² 0.0831 vs the product's
|
||||
0.0803**, and the held-out balanced side accuracy is **0.603 (additive) vs
|
||||
0.601 (product)** — a 0.002 difference, i.e. nothing. Pooled, the two are
|
||||
identical (−0.1005 vs −0.1009; R² 0.0102 vs 0.0101). A TM with two input
|
||||
features **already reconstructs the product term**; the multiplication is what
|
||||
the network was doing anyway.
|
||||
|
||||
**Even the ceiling is out of reach.** The best held-out residual on the required
|
||||
lead at 450+ is **6.68° rms**, against a live hit half-window of **2.29°** — a
|
||||
2.9× shortfall. Pre-multiplying does not get a classifier to 2.29°; nothing in
|
||||
this family does. **Do not build it.** The spec is recorded here so the idea is
|
||||
closed on measurement rather than on taste.
|
||||
|
||||
*(Had it survived, the spec would have been: one TM, ONE input feature `b·t`
|
||||
binarised on sign, plus the 4-bit horizon one-hot as today; offline gate =
|
||||
held-out balanced side accuracy above 0.55 and residual rms below 3° at 450+;
|
||||
live gate = wins/run with CI excluding 0 and sign-flip p<0.05 at 210 runs/arm,
|
||||
damage not detectably down, MDE 0.17 wins/run. Predicted accuracy was 0.60 side
|
||||
accuracy, which is a real signal against the 0.47-0.49 record — and still not
|
||||
close enough to the window to convert.)*
|
||||
|
||||
---
|
||||
|
||||
## 7. The A/B queue, in priority order, with the MDE honestly restated
|
||||
|
||||
Throughput **22.7-23.2 runs/min**; movement gate resolved **0.17 wins/run at 210
|
||||
runs/arm**; the `1/√n` extrapolation to 0.10 wins/run is **607 runs/arm ≈ 1.4 h —
|
||||
a FLOOR on elapsed time, not an estimate**, because opponent heterogeneity does
|
||||
not average down. A null at this sample size **only excludes a LARGE effect.**
|
||||
(j163 additionally measured 14-22 runs/min, not 22.7-23.2, so even the floor is
|
||||
optimistic.)
|
||||
|
||||
| # | experiment | what it tests | cost | a null would license |
|
||||
|---|---|---|---|---|
|
||||
| **1** | **The lead-model time-of-flight correction** (j167's (b)): re-derive the gun's arrival prediction so the implied flight is the power-derived tick, not 10 ticks short. | The one component that carries 2.2× the error variance at 450+, and the only open axis in `docs/gun_campaign.md` (lead *information*, not amplitude). | Offline gate first: arrival aim error at 450+ must fall below 11.26° mean-abs on held-out battles, ideally <8°; only then 2 arms × 15 opponents × 14 runs = 420 battles ≈ **0.3-0.4 h** wall. | Closing the single open gun axis. Nothing left in the gun. |
|
||||
| 2 | `TR_TFIL_RING_COMMIT_ARRIVAL` (j165, default off) | Whether the largest surviving *movement* mechanism (reach 0.24% → 3.05%, picks 5 521 → 525, 148 guards) converts to wins. | 210 runs/arm ≈ **1.4 h floor**. | Retiring the whole ring/approach programme: if even a 12× reach gain is outcome-null, the ceiling argument is confirmed end to end. |
|
||||
| 3 | `TR_FIRE_LAG` (tfil/strafe, default off) | Nothing worth testing — its ceiling is now measured at **~10% of incoming damage ≈ 20 dmg/run**, below the MDE. | Would be 1.4 h to learn nothing. | Nothing. **Skip it**; the measurement has already answered it. |
|
||||
| 4 | `TR_TFIL_ARRIVE_TICKS` (j151, default off) | Whether a hard arrival bound converts now that the ring is rehabilitated. | 1.4 h. | Retiring it with j151's own null attached. |
|
||||
| 5 | `TR_RAM_FLOOR_ENERGY` (j160, j163) | **Under test in j163. DO NOT DUPLICATE.** | — | — |
|
||||
|
||||
**Recommendation.** Run **only experiment 1**, and only after the *offline* gate
|
||||
passes; if the offline gate does not move the 450+ arrival error below ~8°, run
|
||||
nothing at all. Given five consecutive nulls or near-nulls (j144, j145, j146,
|
||||
j147, j159) plus a clean negative in j163, spending 1.4 h of live time on
|
||||
experiments 2-4 is not justified — those are mechanism-positive
|
||||
mechanisms whose outcome nulls are already the standing record, and a null there
|
||||
teaches nothing that the ledger does not already say.
|
||||
|
||||
**"The ceiling is real and we should stop spending on movement" is the answer.**
|
||||
The remaining budget belongs to the gun's lead model, or it is not spent.
|
||||
@@ -0,0 +1,209 @@
|
||||
# j159 — PRE-REGISTRATION: does tile-geometry weighting in the tfil picker win?
|
||||
|
||||
**Written and committed BEFORE a single battle of this experiment ran.** No
|
||||
result in the "MEASURED" section below existed when this section was written.
|
||||
|
||||
## The question
|
||||
|
||||
`TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` (shipped default `off`, j152) shape the
|
||||
**draw** over the safe tiles the tfil picker chooses from. The offline sweep on
|
||||
recorded fixtures predicted a real geometric improvement —
|
||||
|
||||
| offline metric (`measure_tfil_pick_defects`) | `off` | `both-rej`, tau 60 |
|
||||
|---|---:|---:|
|
||||
| REACH / arrival within feasible time | 4.5% | 29.4% |
|
||||
| hot on arrival (`hotAtTta`) | 31.0% | 24.0% |
|
||||
| **top-1 tile share (pick DIVERSITY)** | **7.0%** | **10.6%** |
|
||||
|
||||
— and a **diversity cost**, because the geometry weight concentrates the draw.
|
||||
The offline ruler replays the real picker on real fixtures; it says nothing
|
||||
about whether a tile that is geometrically reachable and less hot actually wins
|
||||
a round. That is what this run measures.
|
||||
|
||||
**Hypothesis H1.** Weighting the draw by tile geometry (`both-rej`, tau 60)
|
||||
raises damage/run and round-win rate over the shipped uniform draw, because the
|
||||
mover arrives at safe tiles instead of merely picking them.
|
||||
|
||||
**Direction is pre-registered as two-sided.** A regression is as interesting as
|
||||
a win (the diversity cost makes one plausible) and re-deciding the direction
|
||||
after seeing the data is exactly what this document exists to prevent.
|
||||
|
||||
## Arms — identical except the geo knob
|
||||
|
||||
Both arms: `TR_MOVEMENT=tfil`, everything else at the shipped defaults, one
|
||||
frozen binary built once from `git archive HEAD` (session 2223ca6).
|
||||
|
||||
| arm | env | role |
|
||||
|---|---|---|
|
||||
| `A_off` | `TR_TFIL_GEO_MODE=off` (`TAU=0`) | **REFERENCE** — the shipped uniform draw |
|
||||
| `B_geo` | `TR_TFIL_GEO_MODE=both-rej` `TR_TFIL_GEO_TAU=60` | treatment |
|
||||
|
||||
**Contamination control (the main risk).** The owner has `both-rej` in a personal
|
||||
`.env` (`ModularBot_garage/out/.env`, a copy at `tr_bots/ModularBot_geo/.env`),
|
||||
and this bot's dotenv loader gives the FILE priority over shell exports. If the
|
||||
tournament's bot instances resolved that file, arm A would silently become arm B
|
||||
and the whole run would be void. Three guarantees, all verifiable from the logs:
|
||||
|
||||
1. each arm is launched with `TR_ENV_FILE` pointing at a **per-arm file this
|
||||
job generated** (`/tmp/j159_geo/env/A_off.env`, `/tmp/j159_geo/env/B_geo.env`)
|
||||
in this job's own outdir, so the ONLY `.env` the loader can resolve is mine;
|
||||
2. the tournament's per-run botdir (`$OUTDIR/.work/<opp>/<arm>/run<N>/bots/ModularBot`)
|
||||
contains only `ModularBot.json`, `ModularBot.sh` and a symlink to the frozen
|
||||
binary — **no `.env`**, and the loader's fallback is `./.env` then `.env` next
|
||||
to the executable (it does not walk up parent directories), so the owner's file
|
||||
is not reachable;
|
||||
3. every single run's `[env]` boot report is checked for its intended
|
||||
`TR_TFIL_GEO_MODE` / `TR_TFIL_GEO_TAU` before any number is read. **Any run
|
||||
whose `[env]` disagrees with its arm invalidates the session** and the run is
|
||||
reported as void rather than analysed.
|
||||
|
||||
## Design
|
||||
|
||||
* Harness: `tools/ab/tournament_run.sh` + `tools/ab/tournament_analyze.py`
|
||||
(unmodified).
|
||||
* Panel: `tools/ab/panel_movement.txt` — the **FROZEN 15-opponent movement
|
||||
panel**, unchanged. Unit of evidence is the opponent, not the battle.
|
||||
* 15 opponents x 2 arms x **14 runs** x 3 rounds = **420 battles**.
|
||||
* Battles serialised: `--wait-arena 45`, one session at a time.
|
||||
|
||||
### Primary metrics (pre-registered, fixed)
|
||||
|
||||
1. **damage/run** (our damage dealt per run)
|
||||
2. **round-win rate** (rounds won / rounds fought)
|
||||
|
||||
**Hit rate is NOT a primary metric** — it hid a survival regression once already.
|
||||
|
||||
### Secondary / mechanism (reported, never a verdict)
|
||||
|
||||
* incoming hit rate (the survival channel the mechanism actually runs through);
|
||||
* damage taken/run;
|
||||
* the offline geometric numbers above (REACH%, hot-on-arrival%, top-1 tile
|
||||
share). The live battle logs do not contain the per-pick tile or the arrival
|
||||
state, so the live run **cannot** re-measure them; that is stated in the
|
||||
verdict rather than papered over. No new instrumentation is built for this.
|
||||
|
||||
### Statistical treatment
|
||||
|
||||
Same as every previous movement gate: per-opponent paired deltas (arm −
|
||||
reference), mean delta, SD, SE, 95% CI, a sign test and a **sign-flip
|
||||
permutation test** (exact when `2^n <= 2^20`, else Monte-Carlo), Wilcoxon as a
|
||||
cross-check, plus the MDE the analyzer reports for the reference arm's n.
|
||||
|
||||
### MDE — stated up front, and it is LARGE
|
||||
|
||||
At **14 runs/arm** over the frozen 15-opponent panel this design resolves about
|
||||
**0.28 wins/run** (and the corresponding damage/run MDE the analyzer prints).
|
||||
A two-arm run is 420 battles, ~1 hour. Resolving **0.10 wins/run** would need
|
||||
~2.2 h and ~2,900 battles — **which we are NOT doing.**
|
||||
|
||||
**Consequences, recorded before any data:**
|
||||
|
||||
* **A null is the likely outcome.** This would be the *fifth* consecutive
|
||||
mechanism-positive / outcome-null result in this campaign (after j144, j145,
|
||||
j146, j147).
|
||||
* A null here **excludes only a LARGE effect** (>= ~0.28 wins/run). It does not
|
||||
show the knob does nothing, and it does not retract the offline geometric
|
||||
measurement.
|
||||
* Because the offline sweep also measured a **diversity regression**
|
||||
(top-1 tile share 7.0% -> 10.6%), a null combined with a confirmed diversity
|
||||
cost is an argument **against** shipping, not for it.
|
||||
|
||||
### Verdict rule (fixed now, not re-read later)
|
||||
|
||||
* **Adopt** only if BOTH primaries move in B's favour with `p(sign-flip) < 0.05`
|
||||
and the effect is at or above the reported MDE. One primary at p<0.05 with
|
||||
the other not down is reported as a partial signal, not a win.
|
||||
* Otherwise **do not ship**; the knob stays default `off`.
|
||||
* The mechanism is reported as measured, with no vote in the verdict.
|
||||
* No subsetting, no dropping opponents, no re-running to chase a p-value. A
|
||||
clean null is a fully acceptable result.
|
||||
|
||||
---
|
||||
|
||||
## MEASURED
|
||||
|
||||
*(appended after the battles — everything above was committed first)*
|
||||
### MEASURED — the live A/B, 420 battles (j159)
|
||||
|
||||
* **Provenance.** Session `/tmp/ab/j159_geo`, commit `7c5bc9c`, frozen binary
|
||||
sha256 `9f116e7a9eb9…`, panel `tools/ab/panel_movement.txt` (FROZEN, 15
|
||||
opponents), 15 x 2 x **14 runs** x 3 rounds = **420 battles, 0 failed, 0 never
|
||||
started, 1110 s**. Per-arm env files `/tmp/j159_geo/env/{A_off,B_geo}.env`.
|
||||
* **Env verification.** All **420** runs carry their intended arm: 210/210
|
||||
`A_off` show `TR_TFIL_GEO_MODE=off` / `TR_TFIL_GEO_TAU=0` (parsed `off`/`0.0`),
|
||||
210/210 `B_geo` show `both-rej`/`60` (parsed `both`/`60.0`), every run reports
|
||||
`env file: /tmp/j159_geo/env/<arm>.env (source: TR_ENV_FILE)` and
|
||||
`move.effective = tfil`. No `.env` exists anywhere in the session dir, and the
|
||||
loader's fallbacks are `./.env` then `.env` next to the executable — it does
|
||||
not walk up parents — so the owner's file is unreachable. **0 runs mis-set.**
|
||||
* **Record correction (no battle re-run).** The arms file declared only
|
||||
`TR_ENV_FILE`, which the analyzer's liveness guard reads from `session.json`
|
||||
and treats as an undeclared `TR_MOVEMENT` (fatal contamination). `session.json`
|
||||
and a corrected arms file were rewritten to declare the effective env — the
|
||||
original is kept as `/tmp/j159_geo/{session.json.orig,arms_tfil_geo.txt.orig}`.
|
||||
The guard then re-verified all 420 declared values verbatim in the boot
|
||||
reports: `liveness: 0 run(s) excluded (420 total)`.
|
||||
|
||||
**Pooled dashboard (descriptive, NOT the verdict):**
|
||||
|
||||
| arm | runs | dmg/run | dmg taken/run | wins/run | round wins | win rate | incoming hit rate | mean distance |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `A_off` | 210 | 112.5 | 195.2 | 1.21 | 255/630 | 40.5% | 17.64% | 393 |
|
||||
| `B_geo` | 210 | 103.7 | 188.0 | 1.16 | 244/630 | 38.7% | 17.42% | 419 |
|
||||
|
||||
**Verdict layer** (per-opponent paired deltas, arm − reference; sign-flip is
|
||||
the exact 2^15 permutation the pre-registration names as the decision test):
|
||||
|
||||
| arm | metric | mean Δ | 95% CI | sign test | p(sign) | **p(sign-flip)** | Wilcoxon p | MDE |
|
||||
|---|---|---:|---|---:|---:|---:|---:|---:|
|
||||
| `B_geo` | **damage/run** | **-8.83** | [-14.69, -2.97] | 5/15 | 0.3018 | **0.006104** | 0.0115 | 7.65 |
|
||||
| `B_geo` | round wins | -0.05 | [-0.18, +0.08] | 4/11 | 0.5488 | 0.4619 | 0.3496 | 0.17 |
|
||||
| `B_geo` | damage taken | -7.24 | [-20.84, +6.36] | 8/15 | 1 | 0.2786 | 0.4777 | 17.77 |
|
||||
| `B_geo` | incoming hit rate | -1.19 pp | [-3.88, +1.51] | 8/15 | 1 | 0.3962 | 0.5895 | 3.51 |
|
||||
| `B_geo` | mean distance | **+26.3 px** | [+16.3, +36.4] | **15/15** | 6.1e-05 | 6.1e-05 | 0.0007 | 13.15 |
|
||||
|
||||
**Per-opponent damage/run** (the pattern/ram rows carry the loss): Coriantumr
|
||||
-25.1, CassiusClay -24.2, SpinBot -26.2, WallAvoider -13.3, BlitzBat -11.2,
|
||||
HawkOnFire -11.7, Diamond -10.9, TripHammer -9.0, YersiniaPestis -8.9,
|
||||
Dookious -7.3 vs GresSuffurd +2.3, DiamondStealer +3.2, Ascendant +3.7,
|
||||
DrussGT +0.1. Round wins: HawkOnFire +0.50 and WallAvoider +0.14 (both closer
|
||||
opponents) against Coriantumr -0.57, TripHammer -0.21, YersiniaPestis -0.21.
|
||||
|
||||
**Mechanism, offline (the committed ruler, re-run unchanged for this doc):**
|
||||
`measure_tfil_pick_defects` reproduces the pre-registered prediction exactly —
|
||||
REACH **4.5% -> 29.4%**, hot-on-arrival **31.0% -> 24.0%**, and the diversity cost
|
||||
**top-1 tile share 7.0% -> 10.6%** (normalised entropy 0.87 -> 0.85). The live
|
||||
mean distance **+26 px on 15/15 opponents** is the same mechanism seen end to
|
||||
end: the geometry weight prefers tiles that are far better to *arrive* in, and
|
||||
the bot sits further out and deals **less** damage.
|
||||
|
||||
### VERDICT — DO NOT ADOPT
|
||||
|
||||
1. **H1 is rejected.** Round wins are flat (-0.05/run, p(sign-flip) = 0.46, under
|
||||
the 0.17 MDE) and damage/run is **down 8.83** (p(sign-flip) = 0.0061, above
|
||||
the 7.65 MDE, Wilcoxon p = 0.011). The pre-registration required BOTH
|
||||
primaries up; one is down and significant by the test it named.
|
||||
2. **The MDE, restated.** 14 runs/arm on the frozen 15-opponent panel resolves
|
||||
**0.17 wins/run** and **7.65 damage/run** (better than the 0.28 pre-registered
|
||||
estimate). So this run excludes a large *benefit*; it also positively measures
|
||||
a small *harm* in damage. It says nothing about effects below those numbers.
|
||||
3. **The mechanism moved exactly as predicted, and that is what makes it bad.**
|
||||
REACH/arrival 4.5% -> 29.4% and hot-on-arrival 31.0% -> 24.0% are real and
|
||||
reproducible, but they bought **+26 px of distance** and fewer damage points,
|
||||
not survival: incoming hit rate moved -1.19 pp, a fifth of its own 3.51 MDE.
|
||||
"Arrive at a safe tile" turned out to mean "arrive further away".
|
||||
4. **Diversity worsened, as the offline sweep warned.** Top-1 tile share
|
||||
7.0% -> 10.6%, and the live losses concentrate against the opponents that
|
||||
punish a long-range mover (SpinBot -26.2 damage with a -14.5 pp hit-rate
|
||||
shift, the pattern guns -9 to -25). Outcomes are null-to-negative AND
|
||||
diversity is worse: that is the argument against shipping, exactly the
|
||||
pre-registered case.
|
||||
5. **A null on wins lets us claim only "no large win".** It does not show the
|
||||
knob is inert, and it does not retract the geometric measurement — but the
|
||||
geometry measurement is not an argument for shipping when the live
|
||||
consequence of it is measurably less damage from measurably further away.
|
||||
|
||||
**Ship state: `TR_TFIL_GEO_MODE` stays default `off`. Nothing changes.** Not
|
||||
adopted, not adopted default-off. This is the **fifth** mechanism-positive /
|
||||
outcome-not-positive result in the movement campaign (j144, j145, j146, j147,
|
||||
j159) — and the first one where the mechanism is *anti*-correlated with damage.
|
||||
@@ -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".
|
||||
@@ -0,0 +1,90 @@
|
||||
# j153 proposal (NOT RUN) — `TR_TFIL_HOLD_WHEN_TRAPPED`: hold when no safe tile exists
|
||||
|
||||
**Status: awaiting the owner's approval. No battle, no A/B arm, no tournament has
|
||||
been started.** The knob exists, is registered, defaults to today's behaviour
|
||||
byte-for-byte, and is guarded offline.
|
||||
|
||||
## The owner's claim
|
||||
|
||||
> "if no tile is found to go, to not choose the less dangerous, but to stay
|
||||
> still! the next tick probably the situation already changed and we did not
|
||||
> commit to any dangerous place."
|
||||
|
||||
Today, when the safe set (`pathMaxHeat <= PathDangerThreshold`, after the
|
||||
`CoolestLevels = 2` filter) is too small to draw from, the picker **promotes the
|
||||
2 least-hot blocked tiles** and moves to one of them. The proposal replaces that
|
||||
with `speed 0.0` for that tick only.
|
||||
|
||||
## The knob
|
||||
|
||||
| env | default | meaning |
|
||||
|---|---|---|
|
||||
| `TR_TFIL_HOLD_WHEN_TRAPPED` | `0` (off) | `1` = hold when the safe set is **empty**; today = promote the 2 least-hot blocked tiles |
|
||||
|
||||
- **One tick, never latched.** The hold is taken at the pick site, and the pick
|
||||
site only runs when `commitTicks == 0`, so the next tick re-evaluates the field
|
||||
from scratch. There is deliberately **no max-hold knob** in j153: a counter can
|
||||
only add a way to get stuck. (j154, in flight, adds
|
||||
`TR_TFIL_HOLD_MAX_TICKS` = 0 = off as a *separate* default-off knob.)
|
||||
- **It never interrupts a live commitment.** The hold replaces a *replan*, not a
|
||||
commitment in progress: with `TR_TFIL_COMMIT_ARRIVAL=1` or
|
||||
`TR_TFIL_NOREV_SPEED=4` armed the two do not fight, because the hold branch is
|
||||
downstream of the commitment block and only runs at `commitTicks == 0`.
|
||||
- **The gun keeps firing.** `computeMove` never emits a fire command (the gun
|
||||
lives in the bot's `go()` loop), and the hold `return`s *after* the bullet
|
||||
tracking, so the fire tracker's state on a held tick is bit-identical to a
|
||||
non-held tick (guarded in `common_libs/tests/test_tfil_commit_env.nim`).
|
||||
|
||||
## What the offline harness can and cannot say
|
||||
|
||||
Per `docs/offline_harness_trust.md` the replay harness is trustworthy only for
|
||||
per-gun single-tick prediction on a fixed enemy trajectory; it scored **0/6** on
|
||||
closed-loop questions. "Hold vs move" is a **counterfactual closed-loop** question,
|
||||
so this document contains **no** damage-taken comparison for holding. Only
|
||||
open-loop descriptors of the recorded field are reported
|
||||
(`common_libs/tests/measure_tfil_hold_window.nim`).
|
||||
|
||||
## Proposed A/B (needs approval)
|
||||
|
||||
```
|
||||
TOURNAMENT_NIMCACHE=/tmp/nc_j153 \
|
||||
tools/ab/tournament_run.sh \
|
||||
--arms tools/ab/arms_hold_trapped.txt \
|
||||
--panel tools/ab/panel_movement.txt \
|
||||
--runs 14 --rounds 7 --conc 7 --wait-arena 45 \
|
||||
--reference hold0 \
|
||||
--outdir /tmp/ab/j153_hold
|
||||
|
||||
python3 tools/ab/tournament_analyze.py /tmp/ab/j153_hold --reference hold0
|
||||
```
|
||||
|
||||
Arms file (frozen 15-opponent movement panel, 14 runs/arm, 7 rounds):
|
||||
|
||||
```
|
||||
hold0 | | control = today's promote-the-2 fallback
|
||||
hold1 | TR_TFIL_HOLD_WHEN_TRAPPED=1 | hold one tick when the safe set is empty
|
||||
hold8 | TR_TFIL_HOLD_WHEN_TRAPPED=1 TR_TFIL_HOLD_MAX_TICKS=8 | bounded hold (only if j154 lands)
|
||||
```
|
||||
|
||||
**Primary metrics: damage/run and round-win rate** (NOT hit rate — a movement
|
||||
arm's value flows through the closed loop). **Mechanism metrics:** incoming hit
|
||||
rate, % of picks held, mean distance-to-enemy at a hold, tick-share at speed 0.
|
||||
|
||||
**MDE, stated up front:** ~0.28 wins/run at 14 runs/arm; a two-arm session is
|
||||
~1 h. Resolving ~0.10 wins/run needs ~2.2 h / ~2,900 battles. A 1 h two-arm run
|
||||
can only reject effects at or above ~0.28 wins/run — anything smaller is a null
|
||||
by construction, and must be reported as such.
|
||||
|
||||
**Prior, stated plainly:** four mechanism-positive / outcome-null results in a
|
||||
row on this campaign. **A null is the most likely outcome.** A null with a
|
||||
mechanism hit (holds fire, distance at hold is large) would mean: holding is
|
||||
achievable and does not by itself buy rounds; ship nothing. A null *without* a
|
||||
mechanism hit means the arm never bound and the A/B is void, not negative.
|
||||
|
||||
## Decision each duration supports
|
||||
|
||||
| duration | supports |
|
||||
|---|---|
|
||||
| 1 h (2 arms × 14 runs) | reject/accept only ≥0.28 wins/run. Mechanism check only. |
|
||||
| 2.2 h (~2,900 battles, 3 arms) | resolve ~0.10 wins/run. Still not a small-effect test. |
|
||||
| any null | no change to the shipped default. `TR_TFIL_HOLD_WHEN_TRAPPED` stays 0. |
|
||||
@@ -0,0 +1,38 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# arms_fire_lag.txt — j147: the ONE-TICK DETECTION LAG, four arms on the frozen
|
||||
# movement panel (tools/ab/panel_movement.txt). Two movers (tfil, strafe) x
|
||||
# {lag 0, lag 1}; the lag knob is the ONLY difference inside a mover pair.
|
||||
#
|
||||
# WHY. MEASURED LIVE (common_libs/tests/measure_fire_ghost_lag.py, 4 sessions,
|
||||
# 1777 matched ghost spawns): the server dispatches a turn's fire AFTER our
|
||||
# go() for that same turn, so the energy drop of a turn-T shot first reaches our
|
||||
# scan at turn T+1 (our bot tick T). A bullet takes its FIRST step during the
|
||||
# turn it is fired, so by then the true bullet is already `speed` px (11..20 px,
|
||||
# one whole bullet step) downrange and the arrival deadline is a full tick short.
|
||||
# Both movers place the ghost at the SCANNED enemy position, i.e. exactly where
|
||||
# the bullet was born: the whole ghost trajectory is the true one shifted one
|
||||
# turn later. The measured ghost-vs-observer displacement is 19.1 px mean /
|
||||
# 22.0 p90 (tfil) and 16.1 / 21.8 (strafe), deadline error 0.99 / 0.77 ticks.
|
||||
# With TR_FIRE_LAG=1 the displacement falls to 5.4 / 9.0 px (the residue is the
|
||||
# ENEMY's own scan staleness, <= 8 px) and the deadline error to 0.06 ticks.
|
||||
#
|
||||
# `TR_FIRE_LAG` default 0 = today's behaviour byte for byte, so arm 1 and arm 3
|
||||
# ARE the shipped movers.
|
||||
#
|
||||
# Pre-registered in docs/movement_campaign.md ("Batch 8 — the fire-detection
|
||||
# lag") BEFORE any of these battles ran. References: `tfil_off`, `strafe_off`.
|
||||
#
|
||||
# Format: name | ENV=value ENV=value | label
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# 1. tfil as shipped (the reference for arm 2).
|
||||
tfil_off | TR_MOVEMENT=tfil | today's tfil, the ghost is born at the scanned enemy (reference)
|
||||
|
||||
# 2. tfil with the measured lag back-dated at spawn.
|
||||
tfil_lag1 | TR_MOVEMENT=tfil TR_FIRE_LAG=1 | tfil, every detected fire back-dated one bullet step
|
||||
|
||||
# 3. strafe as shipped (the reference for arm 4).
|
||||
strafe_off | TR_MOVEMENT=strafe | today's strafe, the ghost is born at the scanned enemy (reference)
|
||||
|
||||
# 4. strafe with the measured lag back-dated at spawn.
|
||||
strafe_lag1 | TR_MOVEMENT=strafe TR_FIRE_LAG=1 | strafe, every detected fire back-dated one bullet step
|
||||
Reference in New Issue
Block a user