## Guard for the Pattern gun's radial-offset knob (`TR_PATTERN_RAD_OFFSET` / ## `TR_PATTERN_RAD_SCALE`). ## ## Proves the two things the shipped-default contract requires: ## 1. BYTE-IDENTITY — an unconfigured gun (env unset, the shipped path) and a ## gun explicitly set to (1.0, 0.0) produce EXACTLY the same predictions as ## a gun whose knob is never touched, over a real DrussGT fixture. The ## (1.0, 0.0) case short-circuits to the raw aim point, so this is a direct ## proof the shipped prediction is unchanged by the knob. ## 2. ENV — `TR_PATTERN_RAD_SCALE=x` scales only the aim DISTANCE, and ## `TR_PATTERN_RAD_OFFSET=px` shifts only the aim DISTANCE; the BEARING ## (the direction the real gun is pointed, `aimAngle(self, pred)`) is ## unchanged. This is the structural reason a radial offset cannot change ## the real shot direction. ## ## Run: nim c -r --path:common_libs common_libs/tests/test_pattern_radial_offset.nim import std/[math, os, strformat] import gun_harness/offline_range import gun_harness/virtual_bullets import guns/pattern_matcher const repoRoot = currentSourcePath().parentDir.parentDir.parentDir fixture = repoRoot / "tools" / "fixtures" / "drussgt_vs_crazy.jsonl" var failures = 0 proc check(name: string, ok: bool) = if ok: echo "PASS: ", name else: echo "FAIL: ", name; inc failures proc clearEnv() = delEnv("TR_PATTERN_RAD_OFFSET") delEnv("TR_PATTERN_RAD_SCALE") proc bearingsClose(a, b: GunPrediction, sx, sy: float): bool = let ba = arctan2(a.y - sy, a.x - sx) let bb = arctan2(b.y - sy, b.x - sx) var d = ba - bb while d > PI: d -= 2.0 * PI while d < -PI: d += 2.0 * PI abs(d) < 1e-12 proc dist(p: GunPrediction, sx, sy: float): float = hypot(p.x - sx, p.y - sy) when isMainModule: let fx = loadFixture(fixture) # First 600 states is plenty to exercise the linear fallback AND the pattern # path (history + a match) without a long compile/run. var states = fx.states if states.len > 600: states.setLen 600 # ── 1. default-path byte-identity ───────────────────────────────────────── clearEnv() var plain = PatternMatcherGun() # never configured -> env path (1.0, 0.0) var explicit = PatternMatcherGun() explicit.setRadialCorrection(1.0, 0.0) # explicit shipped-default values var identical = true var nPred = 0 for si in 0.. 1e-6: inc scaledPoints if abs(ds - d0 * 0.95) > 1e-6: distOK = false if not bearingsClose(p0, ps, s.selfX, s.selfY): bearingOK = false else: if p0.x != ps.x or p0.y != ps.y: distOK = false check &"env scale: scale=0.95 gives dist*0.95 on {scaledPoints} valid preds", distOK check "env scale: bearing unchanged (real aim angle invariant)", bearingOK # ── 3. env offset shifts distance only, keeps bearing ───────────────────── clearEnv() var h0 = PatternMatcherGun() h0.setRadialCorrection(1.0, 0.0) putEnv("TR_PATTERN_RAD_OFFSET", "-20") var hO = PatternMatcherGun() var offOK = true var offBearingOK = true var offPoints = 0 for si in 0.. 20.0: inc offPoints if abs(dof - (d0 - 20.0)) > 1e-6: offOK = false if not bearingsClose(p0, po, s.selfX, s.selfY): offBearingOK = false check &"env offset: offset=-20 gives dist-20 on {offPoints} valid preds", offOK check "env offset: bearing unchanged (real aim angle invariant)", offBearingOK clearEnv() # ── 4. unparsable env falls back to the shipped defaults ────────────────── putEnv("TR_PATTERN_RAD_SCALE", "banana") putEnv("TR_PATTERN_RAD_OFFSET", "nope") var gBad = PatternMatcherGun() discard gBad.predict(states[0], bulletSpeed(PowerBins[0])) clearEnv() var gDef = PatternMatcherGun() discard gDef.predict(states[0], bulletSpeed(PowerBins[0])) var fallbackOK = true for si in 0.. 0: echo "\n", failures, " check(s) FAILED" quit(1) echo "\nAll Pattern radial-offset checks passed."