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:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user