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():