fix(pather): scan for the threshold BEFORE the anti-stuck move

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 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-08-28 22:21:06 +02:00
parent f43824f11d
commit 1c19b8bdc8
2 changed files with 51 additions and 25 deletions

View File

@@ -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"]:

View File

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