feat(bot): ship the aim lead, gated at 2 tiles — the re-aim cost, not the prediction, lost the first attempt - #95
Merged
Merged
Conversation
…ediction, is what lost the first attempt
The first attempt lost on 2026-08-11 and was filed as "linear extrapolation of a curving path errs most close in", pending engine-side position logging that did not exist. Built the logging; the diagnosis was wrong. shot events now carry the target's position plus its two preceding frames, and the shooter's position and facing. Two frames because the predictor being scored estimates velocity from the previous displacement, so one cannot reconstruct it. Held index-aligned with enemies rather than on Enemy, which is plain data shared with the map layer and serialized for replays, and allocated only when an event log is attached. Scored against where enemies actually went, leading is MORE accurate at every range — 93.8% of shots, 38-60% less positional error. The cost is the re-aim: bearing rate goes as v_perp/dist, so inside 2 tiles the lead demands 4.6x more turning than accuracy gained, against ~1.2x beyond, and the bot's facing error at 0-2 is already above even Casual's fireAngleEps. That 4.6/1.3/1.2/1.2 profile tracks the original arm's -8.9/-4.4/-3.9/-1.5pp. Gated at 2 tiles, three arms of 80 on staged curl with two identical lead-off arms as a measured null control: +2.8pp at 6-8 tiles (z=3.69) against a +0.3pp null, unchanged at 0-2 (which the gate forces, so it is a mechanism check), guard clean at 8.86 levels/attempt vs 8.35 with a 0.35 null spread. Replicates a +3.6pp (z=6.34) run on a different substrate. Default ON; BOT_AIM_LEAD_MIN_DIST=0 reproduces the old arm exactly. Two tests now state their lead arm explicitly instead of leaning on the default, which is what silently broke them when it flipped.
CI's coverage gate caught branch coverage at 99.49% against a 99.5% threshold: the defensive `if (i < 0) return null` after `indexOf` cannot be reached from a test, because a crosshair target is by construction in the roster. Rather than contrive a test for an unreachable arm, fold the three guards into one. `indexOf` already yields -1 both for "nothing targeted" and for "target not in this roster", and the no-target case is the common one, so a single `i < 0` check is exercised by shots that hit nothing. Back to 99.52%. Also worth noting for whoever runs the gate locally: `npm run coverage` is bare vitest, so it picks up balancing_corpus's own test files and reports 39 phantom failures. CI never sees them because the corpus is fetched on demand. Reproduce the gate with npx vitest run --coverage --exclude "balancing_corpus/**"
# Conflicts: # doc/dev/history.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You asked me to add the position logging and then run the A/B. Both done — and the logging immediately showed the standing diagnosis was wrong, which changed the fix.
Note this stacks conceptually on #94 (still open) but branches off
master, so there's no merge-order dependency.The predictor was never the problem
shotevents now carrytgt(the target's position plus its two preceding frames) andsx/sy/sdx/sdy(shooter position and facing). Two frames, because the predictor being scored estimates velocity from the previous displacement — one frame can't reconstruct it. Scored against where enemies actually ended up, leading is more accurate at every range, beating no-lead on 93.8% of shots:So "linear extrapolation of a curving path errs most close in" is dead. The cost is the re-aim. Bearing rate goes as
v_perp/dist, so inside 2 tiles the lead demands 4.6× more turning than accuracy gained. The bot's aim is keyboard-quantised and its facing error at 0-2 is already 0.0859 rad — above even Casual'sfireAngleEps— so it fires mid-correction. That 4.6/1.3/1.2/1.2 profile tracks the original arm's damage (−8.9/−4.4/−3.9/−1.5pp).The fix, measured
BOT_AIM_LEAD_MIN_DIST = 2.0, read off that cliff.MIN_DIST = 0reproduces the 2026-08-11 arm exactly, keeping the retry single-variable. Three arms of 80 on staged curl, two identicallead-offarms as a measured null control:Guard clean: levels-per-attempt 8.86 vs pooled 8.35, null spread 0.35. 0-2 unchanged is a mechanism check, not a null — the gate disables the lead there, so anything else would mean it wasn't working. And it replicates: an earlier run gave +3.6pp (z=6.34) at 6-8 on a different substrate. Default flipped ON.
A process failure I want on the record
That earlier run was on the wrong substrate.
stage-campaign.mjsrefuses when the tree isn't safe forgit checkout -B; it refused becauseengine.tswas uncommitted; and I piped its output totail -1without reading it. All three arms played the 17-level demo campaign while everything downstream said curl. It surfaced only becauselevels/attemptread 16.5, impossible on a 15-level campaign — so that run's guard was saturated and worthless, the exact failure this repo already documents twice.Two cheap checks now in
history.mdfor any staged capture:ls demo-campaign | wc -limmediately after stagingThe lane half matters independently: an earlier smoke capture emitted no
tgtat all because a lane was on a stale checkout. Also worth knowing —CODEENSTEIN_CAPTURE_LOCAL_ONLY=1does not prevent lane use, it only suppresses the refusal.Verification
2,927 src tests, 532 script tests,
tsc --noEmitclean, all three arms passedverify:event-log. Two existing tests now state their lead arm explicitly rather than leaning on the default — that implicit dependency is what silently broke them when the default flipped, and it would have hidden the change.