From 1c19b8bdc84d3fab4650275c4a4288b35f1e7230 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Fri, 28 Aug 2026 22:21:06 +0200 Subject: [PATCH] fix(pather): scan for the threshold BEFORE the anti-stuck move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch. The previous commit checked the counter after find_abs_node_pos, which is still too late. The anti-stuck block sits BEFORE the scan in the loop body: 790 if _heading_rejects >= MAX: abort <- top-of-loop check 799 if not did_force_move and now - last_move > 3.1: 808 char.move(...) <- the wall-driving guess 826 node_pos_abs = self.find_abs_node_pos(...) <- 3rd rejection recorded 833 if _heading_rejects >= MAX: abort <- too late With two rejections banked, the moment 3.1s elapses the anti-stuck block force-moves along last_direction — driving a wall-wedged character further in — before the third rejection has been recorded. The exact guess this guard exists to prevent stayed reachable on the threshold iteration. The scan and the abort decision now both run ahead of the anti-stuck block, so the counter is current when that decision is made. The ordering test could not catch this: it searched for "taking a random guess" only in the source AFTER find_abs_node_pos, while the guess block sits before that call, so the comparison was against nothing. It now locates the anti-stuck block explicitly and asserts BOTH the scan and the abort precede it. Verified by falsification: restoring the scan-after-guess order fails with "the node scan must run BEFORE the anti-stuck force-move". That is now four times in this codebase where a check was verified by where it sat in the source rather than by whether it ran at the deciding moment. Co-Authored-By: Claude Opus 5 --- src/pather.py | 31 ++++++++++++----------- test/test_pather_abort_fires.py | 45 +++++++++++++++++++++++++-------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/pather.py b/src/pather.py index 48e4c28..cdb8f7a 100644 --- a/src/pather.py +++ b/src/pather.py @@ -795,6 +795,22 @@ class Pather: self._heading_rejects = 0 return False + # SCAN FIRST, then decide. This must sit ahead of the anti-stuck + # block below, not after it: with two rejections already banked, + # that block force-moves along last_direction the moment 3.1s + # elapses — driving a wall-wedged character further in — before + # find_abs_node_pos has recorded the third rejection. Putting the + # scan after it left the exact guess this guard exists to prevent + # reachable on the threshold iteration. + node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir) + if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS: + Logger.warning( + f"Pather: {self._heading_rejects} consecutive low-confidence rejections " + f"for node {node_idx} — aborting traverse instead of guessing" + ) + self._heading_rejects = 0 + return False + # Sometimes we get stuck at rocks and stuff, after a few seconds force a move into the last known direction if not did_force_move and time.time() - last_move > 3.1: if last_direction is not None: @@ -822,21 +838,6 @@ class Pather: break teleport_count += 1 - # Find any template and calc node position from it - node_pos_abs = self.find_abs_node_pos(node_idx, img, threshold=threshold, last_direction=node_last_dir) - # Check the counter HERE, in the same iteration it trips. The - # identical check at the top of the loop never fired once across - # 79 games while 4 random guesses did — the loop does not - # reliably come back round to it after a rejection. Checking - # immediately after the find removes the dependence on control - # flow entirely. - if getattr(self, "_heading_rejects", 0) >= self._MAX_HEADING_REJECTS: - Logger.warning( - f"Pather: {self._heading_rejects} consecutive low-confidence rejections " - f"for node {node_idx} — aborting traverse instead of guessing" - ) - self._heading_rejects = 0 - return False if node_pos_abs is not None: dist = math.dist(node_pos_abs, (0, 0)) if dist < Config().ui_pos["reached_node_dist"]: diff --git a/test/test_pather_abort_fires.py b/test/test_pather_abort_fires.py index 67aaef3..105b76d 100644 --- a/test/test_pather_abort_fires.py +++ b/test/test_pather_abort_fires.py @@ -40,19 +40,44 @@ def test_counter_trips_exactly_at_the_threshold(): ) -def test_abort_is_checked_immediately_after_the_find(): - """Not only at the top of the loop — that placement never fired in 79 games.""" +def test_scan_and_abort_precede_the_anti_stuck_move(): + """The scan must happen BEFORE the anti-stuck force-move, not after it. + + With two rejections already banked, the anti-stuck block force-moves along + last_direction as soon as 3.1s elapses -- driving a wall-wedged character + further in -- before find_abs_node_pos records the third rejection. An abort + placed only AFTER the find therefore still leaves the exact guess this guard + exists to prevent reachable on the threshold iteration. + + An earlier version of this test searched only the source AFTER the find, so + it could not see the guess block at all (which sits before it). It passed + while the hole was open. + """ from pather import Pather src = inspect.getsource(Pather.traverse_nodes) - code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#")) - find_at = code.index("find_abs_node_pos(node_idx") - after = code[find_at:] - guess_at = after.find("taking a random guess") - abort_at = after.find("_MAX_HEADING_REJECTS") - assert abort_at != -1, "no abort check after the find" - if guess_at != -1: - assert abort_at < guess_at, "the post-find abort must precede any further guess" + code = [l for l in src.splitlines() if not l.strip().startswith("#")] + + def first(pred): + return next((i for i, l in enumerate(code) if pred(l)), None) + + find_at = first(lambda l: "find_abs_node_pos(node_idx" in l) + stuck_at = first(lambda l: "did_force_move and time.time() - last_move" in l) + assert find_at is not None, "no node scan found" + assert stuck_at is not None, "no anti-stuck block found" + + abort_after_find = next( + (i for i, l in enumerate(code) if i > find_at and "_MAX_HEADING_REJECTS" in l), + None, + ) + assert abort_after_find is not None, "no abort check after the scan" + assert find_at < stuck_at, ( + "the node scan must run BEFORE the anti-stuck force-move, or the " + "threshold rejection is recorded too late to stop the guess" + ) + assert abort_after_find < stuck_at, ( + "the abort decision must be made BEFORE the anti-stuck force-move" + ) def test_threshold_is_small_enough_to_beat_the_guess():