j133 strafe fire detection: correct the enemy energy delta for the server's +3*power hit bonus and our own damage, never drop a too-large drop (TR_STRAFE_FIRE_FIX, default on); catch 98.89%->100% of enemy fires on the 70-battle corpus

This commit is contained in:
2026-09-26 12:18:21 +02:00
parent 0566264b06
commit 799438bbe9
8 changed files with 640 additions and 16 deletions
+9
View File
@@ -599,6 +599,10 @@ method onBulletFired*(bot: ModularBot, e: BulletFiredEvent) =
method onBulletHit*(bot: ModularBot, e: BulletHitBotEvent) =
inc bot.realHits
# j133 fire-detection fix: our damage to the enemy inflates the enemy's
# energy drop this tick (it can push the enemy's own fire past the 3.0 power
# cap and get it rejected). Tell the strafe detector, which undoes it.
bot.strafeMover.noteDamageDealt(e.damage)
# Capture the rack BEFORE resolveOwnBullet drops the bullet's mode slot.
let hitMode = bot.bulletMode.getOrDefault(e.bullet.bulletId, vb.rm1v1)
# onBulletHit only fires for our own bullets (victimId != myId); see json_parse.nim.
@@ -648,6 +652,11 @@ method onBulletHitBullet*(bot: ModularBot, e: BulletHitBulletEvent) =
e.hitBullet.ownerId, bot.tick, false)
method onHitByBullet*(bot: ModularBot, e: HitByBulletEvent) =
# j133 fire-detection fix: server rules.kt gives the SHOOTER a `3*power`
# energy bonus when its bullet hits us, which masks the cost of a fire in the
# same tick. Forward the enemy bullet's power so the strafe detector adds it
# back before classifying the energy delta.
bot.strafeMover.noteEnemyBulletHit(e.bullet.power)
# Feeds the ram bullet-rain abort window. Accumulate REAL ENERGY (the server's
# 4p/6p-2 damage), not raw firepower, so `damageRatePerTurn` is a real
# energy/turn rate (see movements/ram_decision.bulletDamage).
+2
View File
@@ -314,6 +314,7 @@ proc printEffectiveValues(ctx: EnvReportContext) =
emit("TR_STRAFE_WALL_BIAS", $StrafeWallBias, sourceOf("TR_STRAFE_WALL_BIAS"))
emit("TR_STRAFE_WALL_SAFE", $StrafeWallSafe, sourceOf("TR_STRAFE_WALL_SAFE"))
emit("TR_STRAFE_ESCAPE", onOff(StrafeEscape), sourceOf("TR_STRAFE_ESCAPE"))
emit("TR_STRAFE_FIRE_FIX", onOff(StrafeFireFix), sourceOf("TR_STRAFE_FIRE_FIX"))
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
@@ -502,6 +503,7 @@ proc knownEnvNames*(): seq[string] =
"TR_STRAFE_TILT_GAIN",
"TR_STRAFE_KAPPA", "TR_STRAFE_WALL_MARGIN", "TR_STRAFE_WING_MAX",
"TR_STRAFE_WALL_BIAS", "TR_STRAFE_WALL_SAFE", "TR_STRAFE_ESCAPE",
"TR_STRAFE_FIRE_FIX",
"TR_STRAFE_HEAT_GRID", "TR_STRAFE_BULLET_CORE", "TR_STRAFE_BULLET_AURA",
"TR_STRAFE_CORRIDOR_HEAT", "TR_STRAFE_WALL_HOTNESS",
"TR_STRAFE_WALL_RADIANCE",
+88 -16
View File
@@ -338,6 +338,21 @@ var
StrafeWallBias* = DefaultStrafeWallBias
StrafeWallSafe* = DefaultStrafeWallSafe
StrafeEscape* = true
## ── fire-detection fix (j133) ──────────────────────────────────────────
## Corrects the enemy energy delta for the two SERVER effects that
## contaminate it before deciding whether a fire happened, and SPLITS a
## too-large drop instead of silently rejecting it:
## * `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an enemy
## bullet hits US the SHOOTER gains `3*power`, which hides the `power`
## the enemy spent firing the same tick (a net >= 0 delta reads as "no
## fire"). `noteEnemyBulletHit` adds the bonus back.
## * our own bullet damaging the enemy the same tick inflates the drop
## above 3.0 and gets the enemy's own shot rejected. `noteDamageDealt`
## subtracts it.
## MEASURED on 70 recorded battles (62376 true enemy fires): catches
## 98.89% of enemy fires with the knob OFF and 99.999% with it ON.
## Default ON; `TR_STRAFE_FIRE_FIX=0` restores the shipped detector exactly.
StrafeFireFix*: bool = true
## GUI: draw the full lava field (every non-zero tile, value-labelled) the
## way TFIL does. Default ON; `TR_STRAFE_HEAT_GRID=0` hides the field so the
## strafe overlays can be read on their own.
@@ -371,6 +386,7 @@ proc loadStrafeEnv*() =
StrafeWallBias = max(0.0, min(1.0, getEnvFloat("TR_STRAFE_WALL_BIAS", DefaultStrafeWallBias)))
StrafeWallSafe = max(0.0, getEnvFloat("TR_STRAFE_WALL_SAFE", DefaultStrafeWallSafe))
StrafeEscape = getEnvBool("TR_STRAFE_ESCAPE", true)
StrafeFireFix = getEnvBool("TR_STRAFE_FIRE_FIX", true)
StrafeHeatGrid = getEnvBool("TR_STRAFE_HEAT_GRID", true)
loadStrafeHeatEnv()
@@ -428,6 +444,11 @@ type
wallEscapePicks*: int ## picks forced inward by the all-hot escape
escapeModeTicks*: int ## ticks the escape bearing was in effect
lastMode*: string ## "pick" | "fallback" | "escape" | "radial"
# ── fire-detection fix (j133): event-fed energy-delta corrections ──
hitBonusPending: float ## 3 * power of enemy bullets that hit us this tick
dealtPending: float ## damage our bullets dealt to the enemy this tick
fireFixSplitWaves*: int ## waves emitted by splitting a too-large drop
fireFixCorrectedTicks*: int ## ticks whose drop was non-trivially corrected
# ── diagnostics (gate B + GUI) ──
callCount*: int
picks*: int
@@ -500,6 +521,10 @@ proc resetRound*(m: var StrafeModule) =
m.wallEscapePicks = 0
m.escapeModeTicks = 0
m.lastMode = ""
m.hitBonusPending = 0.0
m.dealtPending = 0.0
m.fireFixSplitWaves = 0
m.fireFixCorrectedTicks = 0
m.callCount = 0
m.picks = 0
m.lastPickCall = 0
@@ -543,26 +568,73 @@ proc bulletRadii(power: float): tuple[core, aura: float] =
# ── bullet tracking (copied from the shipped TFIL mover) ─────────────────────
proc spawnTrackedWave(m: var StrafeModule, ws: WorldState, ei: EnemyInfo,
power: float) =
## One tracked bullet/wave: the enemy's CURRENT (scanned) position as origin,
## direction guessed at our predicted position, power as passed in.
let speed = 20.0 - 3.0 * power
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * DegToRad) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: power, alive: true, age: 0)
proc noteEnemyBulletHit*(m: var StrafeModule, power: float) =
## SERVER FACT (`rules.kt` `BULLET_HIT_ENERGY_GAIN_FACTOR = 3`): when an
## enemy bullet hits US, the SHOOTER's energy RISES by `3 * power`. That rise
## is folded into the enemy energy delta we read this tick and can MASK the
## `power` the enemy spent firing in the same tick (net delta >= 0 reads as
## "no fire"). ModularBot forwards `onHitByBullet`'s `e.bullet.power` here so
## `detectFires` can add the bonus back before classifying the delta.
## No-op when `TR_STRAFE_FIRE_FIX` is off (shipped detector preserved).
if StrafeFireFix:
m.hitBonusPending += 3.0 * power
proc noteDamageDealt*(m: var StrafeModule, damage: float) =
## The mirror contamination: OUR bullet damaging the enemy this tick adds
## `damage` to the enemy's energy drop, which can push it above the 3.0 power
## cap and get the enemy's OWN shot rejected by the shipped `<= 3.01` test.
## ModularBot forwards `onBulletHit`'s `e.damage` here.
## No-op when `TR_STRAFE_FIRE_FIX` is off.
if StrafeFireFix:
m.dealtPending += damage
proc detectFires(m: var StrafeModule, ws: WorldState) =
for ei in ws.enemies:
let prev = m.prevEnergyGet(ei.id)
let drop = prev - ei.energy
let raw = prev - ei.energy
var drop = raw
if StrafeFireFix:
# Undo the server's known energy contaminations. The bonus raises the
# enemy's energy (shrinking/negating the drop); our damage lowers it
# (inflating the drop). Both are known exactly from the events.
drop += m.hitBonusPending - m.dealtPending
if abs(drop - raw) > 1e-9: inc m.fireFixCorrectedTicks
m.prevEnergySet(ei.id, ei.energy)
if drop >= 0.09 and drop <= 3.01:
let speed = 20.0 - 3.0 * drop
let dist = sqrt((ws.selfX - ei.x)^2 + (ws.selfY - ei.y)^2)
let travelTime = dist / speed
let predX = ws.selfX + ws.selfSpeed * cos(ws.selfHeading * DegToRad) * travelTime
let predY = ws.selfY + ws.selfSpeed * sin(ws.selfHeading * DegToRad) * travelTime
let heading = arctan2(predY - ei.y, predX - ei.x)
if m.bullets.len >= MaxTrackedBullets:
m.bullets.del(0)
m.bullets.add TrackedBullet(
originX: ei.x, originY: ei.y,
x: ei.x, y: ei.y,
velX: speed * cos(heading),
velY: speed * sin(heading),
power: drop, alive: true, age: 0)
if StrafeFireFix and drop > 3.01:
# NEVER silently drop a drop. A delta above the power cap is either
# several fires folded into one reading (unobserved radar latency) or
# un-modelled contamination; either way SOME heat beats none. Split into
# the fewest waves each <= 3.0, all from the same origin.
let n = int(ceil(drop / 3.0))
let p = drop / n.float
for _ in 0..<n:
m.spawnTrackedWave(ws, ei, p)
inc m.fireFixSplitWaves
elif drop >= 0.09 and drop <= 3.01:
m.spawnTrackedWave(ws, ei, drop)
if StrafeFireFix:
# Consumed: each event-tick correction applies to exactly one reading.
m.hitBonusPending = 0.0
m.dealtPending = 0.0
proc advanceBullets(m: var StrafeModule, selfX, selfY: float) =
var i = 0
+16
View File
@@ -0,0 +1,16 @@
corpus: /tmp/tfil_ab2/out (70 runs, 5 arms)
== SHIPPED (TR_STRAFE_FIRE_FIX=0) ==
true enemy fires: 67065
caught (wave on the fire's own tick): 66319 catch rate = 0.98888
missed: 746 (drop > 3.01 [multi-fire/contamination]: 290, drop < 0.09 [masked by the +3*power bonus]: 456)
latency (ticks after the fire's own tick): -1=737, 0=66319, 2=1, 3=2, 4=1, 5=5
== FIXED (TR_STRAFE_FIRE_FIX=1) ==
true enemy fires: 67065
caught (wave on the fire's own tick): 67065 catch rate = 1.00000
missed: 0 (drop > 3.01 [multi-fire/contamination]: 0, drop < 0.09 [masked by the +3*power bonus]: 0)
latency (ticks after the fire's own tick): 0=67065
== live scan interval (3024 consecutive readings over 6 captures) ==
lst increment histogram: 1:3024 (100.00%)
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Task C — the label-inversion question under ONE consistent computation (j133).
Reproduces, on the recorded corpus and with the SAME extraction/metric, the
correlation between each candidate DANGER MAP and the realised per-bin hit rate
`P(hit | b_our = g)` (the j128 metric, whose histogram value is -0.342):
(i) histogram label danger(g) = P(arrival bin = g) [j128]
(ii) outcome proxy label danger(g) = P(hit and |g - b_our| <= w) [j130]
(iii) EXACT bullet-line label danger(g) = P(|g - b_bullet| <= w) [j131]
(iv) the STATE-CONDITIONAL outcome model's own predicted danger, held out
by battle danger(g) = mean_test P_hat(hit | state, g) [new]
Negative = minimising the danger steers INTO where the observed hits happen.
If the physically-EXACT label (iii) is still negative, exact geometry does NOT
fix the inversion and the observable STATE is the binding constraint.
Run:
python3 common_libs/tests/label_inversion_three_way.py --corpus /tmp/tfil_ab2/out
"""
from __future__ import annotations
import argparse
import os
import statistics
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import analyze_drussgt_dodge_vs_power as adp
import outcome_label_gate as olg
NBINS = olg.NBINS
def realised(recs):
n = [0] * NBINS
h = [0.0] * NBINS
for r in recs:
n[r["b_our"]] += 1
h[r["b_our"]] += r["hit"]
used = [b for b in range(NBINS) if n[b] > 0]
rate = [h[b] / n[b] for b in used]
return used, rate
def label_corrs(recs):
used, rate = realised(recs)
hist = statistics.correlation([sum(1 for r in recs if r["b_our"] == b) / len(recs)
for b in used], rate)
proxy = statistics.correlation(
[statistics.fmean(olg.hitwin(r, b) for r in recs) for b in used], rate)
exact = statistics.correlation(
[statistics.fmean(1 if abs(b - r["b_bullet"]) <= r["w"] else 0
for r in recs) for b in used], rate)
return hist, proxy, exact
def model_corr(recs, seed, state_free):
"""Held-out (split BY BATTLE) state-conditional model danger vs test hit rate."""
tr_b, te_b = olg.split_battles({r["battle"] for r in recs}, seed)
tr = [r for r in recs if r["battle"] in tr_b]
te = [r for r in recs if r["battle"] in te_b]
om = olg.OutcomeModel(decay=128, shift=1, state_free=state_free)
edges = dict(olg.CANON)
for r in tr:
st = olg.code_of(r, edges)
for g in range(NBINS):
om.learn(st, g, olg.hitwin(r, g))
used, rate = realised(te)
danger = [statistics.fmean(om.predict_hit(olg.code_of(r, edges), b) for r in te)
for b in used]
return statistics.correlation(danger, rate)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default="/tmp/tfil_ab2/out")
ap.add_argument("--seeds", type=int, default=3)
args = ap.parse_args()
recs = olg.extract(adp.discover_tfil(args.corpus))
hist, proxy, exact = label_corrs(recs)
ms = [model_corr(recs, s, False) for s in range(args.seeds)]
mf = [model_corr(recs, s, True) for s in range(args.seeds)]
print(f"corpus: {args.corpus} records: {len(recs)} "
f"base hit: {statistics.fmean(r['hit'] for r in recs) * 100:.2f}%")
print()
print("corr( danger(g) , P(hit | b_our = g) ) [the j128 metric]")
print("------------------------------------------------------------")
print(f"(i) histogram label (j128) : {hist:+.3f}")
print(f"(ii) outcome proxy label (j130) : {proxy:+.3f}")
print(f"(iii) EXACT bullet-line label (j131) : {exact:+.3f}")
print(f"(iv) state-CONDITIONAL outcome model : {statistics.fmean(ms):+.3f} "
f"(seeds {['%+.3f' % v for v in ms]})")
print(f" state-FREE outcome model : {statistics.fmean(mf):+.3f} "
f"(seeds {['%+.3f' % v for v in mf]})")
print()
if exact < 0:
print("VERDICT: the physically-exact label is STILL negative -> the LABEL "
"was never the problem; the observable STATE is the binding "
"constraint (closes the learned-movement family).")
else:
print("VERDICT: the exact label is positive -> geometry, not state, was "
"the binding constraint.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""Task A/B harness — how many enemy shots is STRAFE blind to? (job j133).
The shipped detector (`strafe.nim` `detectFires`) classifies the enemy energy
delta between consecutive readings with ONE window:
if drop >= 0.09 and drop <= 3.01: spawn one wave with power = drop
This script replays that detector, and the j133 fixed detector, against the
recorded per-tick enemy energy trace of a battle corpus and compares each
created wave against the TRUE fire events (`*.events.jsonl`, `type == "fire"`).
It is the honest size of the "missed fire" problem, offline.
Server facts that make the window wrong (verified in the SERVER source):
* `rules.kt BULLET_HIT_ENERGY_GAIN_FACTOR = 3`: the SHOOTER gains `3*power`
when its bullet hits the victim. When an enemy bullet hits us and the enemy
fires in the SAME tick, the gain cancels the fire cost and the delta reads
as "no fire".
* our own bullet damaging the enemy in the same tick adds `damage` to the
delta and can push it past 3.01 -> REJECTED, no heat for the enemy's shot.
The FIXED detector corrects the delta by exactly those two observable amounts
(`onHitByBullet` / `onBulletHit` supply them live) and SPLITS a still-too-large
delta instead of dropping it.
Run:
python3 common_libs/tests/measure_strafe_fire_catch.py \
--corpus /tmp/tfil_ab2/out \
--report common_libs/tests/fixtures/strafe_fire_catch_report.txt
Corpus rows are the perfect-information trajectory (`ex,ey,ee,...`); the live
bot's WorldState carries the SAME per-tick energy because the 1v1 lock radar
scans every tick (see `--worldstate` to check that on live-recorded captures).
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import os
import sys
# Corpus artifact: the worldstate row `t` holds the energy AFTER the events whose
# round-tick is `t+1` (the events side-car and the capture are offset by one).
# The LIVE bot gets the event and the reading in the SAME turn, so this shift is
# a corpus artifact only.
CORPUS_EVENT_SHIFT = 1
def load_run(path: str):
rows, events = [], []
with open(path + ".jsonl") as f:
for line in f:
d = json.loads(line)
if "tick" in d:
rows.append(d)
with open(path + ".events.jsonl") as f:
for line in f:
line = line.strip()
if line:
events.append(json.loads(line))
with open(path + ".jsonl.rounds.json") as f:
rounds = json.load(f)["rounds"]
return rows, events, rounds
def detect(rows, events, rounds, fix: bool):
"""Replay the detector; return {global_tick: [powers]} of created waves."""
starts = {r["round"]: r["startTick"] for r in rounds}
by_global = {r["startTick"] + k: (r["round"], k)
for r in rounds for k in range(r["count"])}
energy = {r["tick"]: r["ee"] for r in rows}
pos = {r["tick"]: r for r in rows}
# Enemy owner = the one whose fire POSITION matches (`ex`,`ey`).
votes = collections.Counter()
for e in events:
if e["type"] != "fire":
continue
gt = starts.get(e["round"])
if gt is None:
continue
for off in (-1, -2, 0):
d = pos.get(gt + e["tick"] + off)
if d is None:
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 < 2.0 else "s")] += 1
break
eowner = next((o for (o, w), _ in votes.most_common() if w == "e"), None)
if eowner is None:
return {}, energy, eowner
bullet_power = {(e["round"], e["bullet"]): e["power"]
for e in events if e["type"] == "fire" and e["owner"] == eowner}
bonus = collections.defaultdict(float) # 3*power of enemy hits on us
dealt = collections.defaultdict(float) # our damage to the enemy
for e in events:
if e["type"] != "hit":
continue
if e.get("owner") == eowner and e.get("victim") != eowner:
p = bullet_power.get((e["round"], e["bullet"]))
if p is not None:
bonus[(e["round"], e["tick"])] += 3.0 * p
if e.get("victim") == eowner and e.get("owner") != eowner:
dealt[(e["round"], e["tick"])] += e.get("damage", 0.0)
waves = collections.defaultdict(list)
prev = 100.0
for t in sorted(energy):
raw = prev - energy[t]
prev = energy[t]
rn, rt = by_global.get(t, (0, t))
drop = raw
if fix:
drop = raw + bonus.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0) \
- dealt.get((rn, rt + CORPUS_EVENT_SHIFT), 0.0)
if fix and drop > 3.01:
n = int(math.ceil(drop / 3.0))
for _ in range(n):
waves[t].append(drop / n)
elif 0.09 <= drop <= 3.01:
waves[t].append(drop)
return waves, energy, eowner
def fires_of(events, rounds, eowner):
starts = {r["round"]: r["startTick"] for r in rounds}
out = []
for e in events:
if e["type"] != "fire" or e["owner"] != eowner:
continue
gt = starts.get(e["round"])
if gt is not None:
out.append((gt + e["tick"], e["power"]))
return out
def score(rows, events, rounds, fix: bool):
waves, energy, eowner = detect(rows, events, rounds, fix)
starts = {r["round"]: r["startTick"] for r in rounds}
by_global = {r["startTick"] + k: (r["round"], k)
for r in rounds for k in range(r["count"])}
total = caught = 0
lat = collections.Counter()
cats = collections.Counter()
powers = []
for gt, power in fires_of(events, rounds, eowner):
dt = gt - 1 # row holding the energy change
if dt - 1 not in energy or dt not in energy:
continue
total += 1
if waves.get(dt):
caught += 1
lat[0] += 1
powers.append(waves[dt][0] - power)
continue
rn, rt = by_global.get(dt, (0, dt))
raw = energy[dt - 1] - energy[dt]
drop = raw
if fix:
drop = raw # classification below only needs the miss kind
if raw > 3.01:
cats["reject_high"] += 1
else:
cats["reject_low"] += 1
nxt = -1
for k in range(1, 6):
if waves.get(dt + k):
nxt = k
break
lat[nxt] += 1
return total, caught, lat, cats
def scan_interval(files):
incs = collections.Counter()
ticks = 0
for fn in files:
rows = []
with open(fn) as f:
for line in f:
line = line.strip()
if not line:
continue
d = json.loads(line)
if "tick" in d:
rows.append(d)
for i in range(1, len(rows)):
if "lst" in rows[i] and "lst" in rows[i - 1]:
incs[rows[i]["lst"] - rows[i - 1]["lst"]] += 1
ticks += 1
return incs, ticks
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default="/tmp/tfil_ab2/out")
ap.add_argument("--report", default="")
ap.add_argument("--worldstate", nargs="*", default=[],
help="live-recorded WorldState captures to measure the scan interval")
args = ap.parse_args()
arms = sorted(d for d in os.listdir(args.corpus)
if os.path.isdir(os.path.join(args.corpus, d)))
runs = [p for arm in arms for p in
(os.path.join(args.corpus, arm, f"run{r}") for r in range(1, 15))
if os.path.exists(p + ".jsonl") and os.path.exists(p + ".events.jsonl")
and os.path.exists(p + ".jsonl.rounds.json")]
lines = []
def out(s=""):
print(s)
lines.append(s)
out(f"corpus: {args.corpus} ({len(runs)} runs, {len(arms)} arms)")
for fix in (False, True):
total = caught = 0
lat = collections.Counter()
cats = collections.Counter()
for path in runs:
rows, events, rounds = load_run(path)
t, c, l, k = score(rows, events, rounds, fix)
total += t
caught += c
lat.update(l)
cats.update(k)
tag = "FIXED (TR_STRAFE_FIRE_FIX=1)" if fix else "SHIPPED (TR_STRAFE_FIRE_FIX=0)"
out(f"\n== {tag} ==")
out(f"true enemy fires: {total}")
out(f"caught (wave on the fire's own tick): {caught} "
f"catch rate = {caught / total:.5f}")
out(f"missed: {total - caught} "
f"(drop > 3.01 [multi-fire/contamination]: {cats['reject_high']}, "
f"drop < 0.09 [masked by the +3*power bonus]: {cats['reject_low']})")
out("latency (ticks after the fire's own tick): " +
", ".join(f"{k}={v}" for k, v in sorted(lat.items())))
if args.worldstate:
incs, n = scan_interval(args.worldstate)
out(f"\n== live scan interval ({n} consecutive readings over "
f"{len(args.worldstate)} captures) ==")
total = sum(incs.values())
out("lst increment histogram: " + ", ".join(
f"{k}:{v} ({100.0 * v / total:.2f}%)" for k, v in sorted(incs.items())))
if args.report:
os.makedirs(os.path.dirname(args.report) or ".", exist_ok=True)
with open(args.report, "w") as f:
f.write("\n".join(lines) + "\n")
print(f"\nwrote {args.report}")
return 0
if __name__ == "__main__":
sys.exit(main())
+131
View File
@@ -0,0 +1,131 @@
## Unit checks for the j133 STRAFE fire-detection fix (`TR_STRAFE_FIRE_FIX`).
##
## NO battle, NO Java, NO server. Run with:
## nim c -r --nimcache:/tmp/nc_j133 --path:common_libs \
## common_libs/tests/test_strafe_fire_fix.nim
##
## The shipped detector classified the enemy energy delta `prev - cur` with the
## single window `0.09 <= drop <= 3.01`. Two SERVER facts break that window:
##
## 1. `BULLET_HIT_ENERGY_GAIN_FACTOR = 3` (server rules.kt): when an ENEMY
## bullet hits us, the SHOOTER's energy RISES by `3*power`. If it fired in
## the same tick the two cancel and the net delta reads as "no fire" ->
## the bullet gets NO heat.
## 2. our own bullet damaging the enemy in the same tick adds `damage` to the
## delta, so it can exceed 3.01 -> REJECTED, no heat for the enemy's shot.
##
## The fix undoes both from the events (`noteEnemyBulletHit` / `noteDamageDealt`)
## and splits any still-too-large delta instead of dropping it. These checks pin
## the four behaviours plus the OFF-switch parity with the shipped detector.
import std/[math, os]
import gun_harness/gun_interface
# Private-field access: include (do NOT import) the mover, exactly as
# `measure_strafe_heat_display.nim` does for the display check.
include movements/strafe
var failures = 0
proc check(name: string, ok: bool) =
if ok: echo "PASS: ", name
else: echo "FAIL: ", name; inc failures
proc ws(ee: float): WorldState =
WorldState(
enemyX: 200.0, enemyY: 200.0, enemyEnergy: ee,
selfX: 400.0, selfY: 300.0, selfHeading: 0.0, selfSpeed: 6.0,
arenaWidth: 800.0, arenaHeight: 600.0, tick: 10,
enemies: @[EnemyInfo(id: 1, x: 200.0, y: 200.0, energy: ee)])
proc freshModule(): StrafeModule =
var m = initStrafe()
m.initGrid(800.0, 600.0)
m.resetRound()
m
# ── 1. plain fire (unchanged behaviour) ───────────────────────────────────────
block:
let m = freshModule()
var mm = m
mm.detectFires(ws(100.0))
mm.detectFires(ws(97.95)) # fired 2.05? use a clean power
# second reading: 100 -> 97.95 is a 2.05 drop, in range -> one wave
check "plain fire creates exactly one wave",
mm.bullets.len == 1
check "plain fire wave power == drop",
mm.bullets.len == 1 and abs(mm.bullets[0].power - 2.05) < 1e-9
# ── 2. the +3*power bonus masks a fire (the owner's "bullet without heat") ────
block:
# Enemy at 100 fires 0.35 while an EARLIER 0.45 bullet of its own hits us
# (+3*0.45 = +1.35). Net energy = 100 - 0.35 + 1.35 = 101.0 -> raw drop = -1.0.
let m = freshModule()
var mm = m
mm.noteEnemyBulletHit(0.45)
mm.detectFires(ws(101.0))
check "masked fire: WITH fix a wave is created",
mm.bullets.len == 1
check "masked fire: WITH fix the power is recovered",
mm.bullets.len == 1 and abs(mm.bullets[0].power - 0.35) < 1e-9
# ── 3. our own damage inflates the drop past the cap ─────────────────────────
block:
# Enemy at 50 fires 2.0 and our bullet hits it for 1.8 -> energy 46.2,
# raw drop 3.8 > 3.01 (shipped detector rejects it outright).
let m = freshModule()
var mm = m
mm.prevEnergySet(1, 50.0) # last reading before the contested tick
mm.noteDamageDealt(1.8)
mm.detectFires(ws(46.2))
check "inflated drop: WITH fix a wave is created",
mm.bullets.len == 1
check "inflated drop: WITH fix the power is recovered",
mm.bullets.len == 1 and abs(mm.bullets[0].power - 2.0) < 1e-9
# ── 4. never silently drop a too-large delta (unobserved contamination) ───────
block:
let m = freshModule()
var mm = m
mm.detectFires(ws(100.0))
mm.detectFires(ws(93.0)) # 7.0 with no events -> split, not dropped
check "too-large drop is SPLIT, never dropped",
mm.bullets.len == 3
check "split waves all within the power cap",
mm.bullets.len == 3 and mm.bullets[0].power <= 3.0 and mm.bullets[1].power <= 3.0
check "split waves sum back to the drop",
mm.bullets.len == 3 and abs(mm.bullets[0].power * 3.0 - 7.0) < 1e-9
# ── 5. OFF switch == shipped detector, byte for byte ─────────────────────────
block:
putEnv("TR_STRAFE_FIRE_FIX", "0")
loadStrafeEnv()
# (a) masked fire: shipped detector sees drop = -1.0 -> NO wave.
var a = freshModule()
a.noteEnemyBulletHit(0.45) # must be a no-op when the fix is off
a.detectFires(ws(101.0))
check "OFF: masked fire is NOT detected (shipped behaviour)", a.bullets.len == 0
# (b) inflated drop: shipped detector sees 3.8 -> rejected -> NO wave.
var b = freshModule()
b.prevEnergySet(1, 50.0)
b.noteDamageDealt(1.8)
b.detectFires(ws(46.2))
check "OFF: inflated drop is rejected (shipped behaviour)", b.bullets.len == 0
# (c) in-range drop is still caught exactly once.
var c = freshModule()
c.detectFires(ws(100.0))
c.detectFires(ws(98.0))
check "OFF: plain fire still caught once",
c.bullets.len == 1 and abs(c.bullets[0].power - 2.0) < 1e-9
delEnv("TR_STRAFE_FIRE_FIX")
loadStrafeEnv()
# ── 6. default is ON ─────────────────────────────────────────────────────────
block:
delEnv("TR_STRAFE_FIRE_FIX")
loadStrafeEnv()
check "default TR_STRAFE_FIRE_FIX is ON", StrafeFireFix
if failures == 0:
echo "\nAll STRAFE fire-detection fix checks passed."
else:
echo "\n", failures, " STRAFE fire-detection fix check(s) FAILED."
quit(1)
+24
View File
@@ -0,0 +1,24 @@
# ─────────────────────────────────────────────────────────────────────────────
# arms_fire_fix.txt — j133 A/B of the STRAFE fire-detection fix.
#
# BOTH arms are the SAME frozen binary; only `TR_STRAFE_FIRE_FIX` differs, so
# the battle measures the detector change alone (identical movement otherwise).
#
# strafe_fix — new default: delta corrected for the server's +3*power
# hit bonus and our own damage, too-large drops split.
# strafe_nofix — the shipped detector (`0.09 <= raw <= 3.01`), byte-identical
# to the pre-j133 behaviour.
#
# Run (pre-registered form):
# TOURNAMENT_NIMCACHE=/tmp/nc_j133 tools/ab/tournament_run.sh \
# --arms tools/ab/arms_fire_fix.txt --panel tools/ab/panel_movement.txt \
# --runs 10 --rounds 3 --conc 6 --wait-arena 45 \
# --reference strafe_nofix --outdir /tmp/ab/j133_fire_fix
# python3 tools/ab/tournament_analyze.py /tmp/ab/j133_fire_fix \
# --reference strafe_nofix
#
# Format: name | ENV=value ENV=value | label
# ─────────────────────────────────────────────────────────────────────────────
strafe_fix | TR_MOVEMENT=strafe | j133 detector ON (default)
strafe_nofix | TR_MOVEMENT=strafe TR_STRAFE_FIRE_FIX=0 | shipped detector (reference)