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