diff --git a/ModularBot_garage/src/ModularBot.nim b/ModularBot_garage/src/ModularBot.nim index 92e0b77..87e70a2 100644 --- a/ModularBot_garage/src/ModularBot.nim +++ b/ModularBot_garage/src/ModularBot.nim @@ -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). diff --git a/ModularBot_garage/src/env_report.nim b/ModularBot_garage/src/env_report.nim index 9d14ad1..8968ecb 100644 --- a/ModularBot_garage/src/env_report.nim +++ b/ModularBot_garage/src/env_report.nim @@ -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", diff --git a/common_libs/movements/strafe.nim b/common_libs/movements/strafe.nim index 985af14..50e5cef 100644 --- a/common_libs/movements/strafe.nim +++ b/common_libs/movements/strafe.nim @@ -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..= 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 diff --git a/common_libs/tests/fixtures/strafe_fire_catch_report.txt b/common_libs/tests/fixtures/strafe_fire_catch_report.txt new file mode 100644 index 0000000..1193d72 --- /dev/null +++ b/common_libs/tests/fixtures/strafe_fire_catch_report.txt @@ -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%) diff --git a/common_libs/tests/label_inversion_three_way.py b/common_libs/tests/label_inversion_three_way.py new file mode 100644 index 0000000..78d8c38 --- /dev/null +++ b/common_libs/tests/label_inversion_three_way.py @@ -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()) diff --git a/common_libs/tests/measure_strafe_fire_catch.py b/common_libs/tests/measure_strafe_fire_catch.py new file mode 100644 index 0000000..2b0f82f --- /dev/null +++ b/common_libs/tests/measure_strafe_fire_catch.py @@ -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()) diff --git a/common_libs/tests/test_strafe_fire_fix.nim b/common_libs/tests/test_strafe_fire_fix.nim new file mode 100644 index 0000000..337ef49 --- /dev/null +++ b/common_libs/tests/test_strafe_fire_fix.nim @@ -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) diff --git a/tools/ab/arms_fire_fix.txt b/tools/ab/arms_fire_fix.txt new file mode 100644 index 0000000..4c1a99a --- /dev/null +++ b/tools/ab/arms_fire_fix.txt @@ -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)