diff --git a/CLAUDE.md b/CLAUDE.md index 99d6390..c6fb891 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -589,7 +589,46 @@ exact failing case (repair started from `a4_town_start`) and completed successfu **Residual, different cause:** the failures that remain are NPC-detection flakiness — e.g. `open_npc_menu: timed out finding qual_kehk` during merc resurrect burns ~40s and strands the -char. Same family as Bugs 3/4/6/7, not act desync. +char. Same family as Bugs 3/4/6/7, not act desync. See Bug 29. + +### Bug 29: undetectable resurrect NPC burned ~80s in EVERY game (2026-08-27) +**Files:** `src/bot.py`, `src/game_stats.py` + +In nightmare the merc dies most games, so `resurrect_merc` runs constantly — and Qual-Kehk +detection was failing **100% of the time** (5 timeouts in 5 attempts, 107 hover attempts +across 12 games). Each failed hunt costs ~40s, and the code retried once, so a dead merc cost +**~80s per game, forever**. Game length blew out to 185-250s versus a normal ~60s. + +**Why the existing guard didn't help:** `GameStats._merc_resurrect_failed` is reset in +`log_start_game`, so it only suppresses a second attempt *within one game*. Nothing carried +the knowledge across games. + +**The name tag template is degenerate, not merely stale.** Every grid-sweep "hit" reported +the *identical* score at completely different screen positions: +``` +grid sweep found name tag at (255, 227) (score 0.424) +grid sweep found name tag at (1110, 100) (score 0.424) +grid sweep found name tag at (930, 310) (score 0.424) +``` +An identical score at unrelated positions is a constant/degenerate match — the template is +matching uniform background, so "found" is meaningless. **If you see a repeated identical +match score, suspect the template, not the search.** + +**Fix (cost containment, not detection):** a cross-game circuit breaker on `GameStats`, +deliberately NOT reset by `log_start_game`: +- `_merc_resurrect_fail_streak` — consecutive failed resurrects +- `_merc_resurrect_skip_until` — game number to resume trying at +- `Bot._MERC_RESURRECT_FAIL_LIMIT = 2`, `_MERC_RESURRECT_SKIP_GAMES = 15` +- the retry is skipped once the streak is ≥1 (a second guaranteed-futile 40s hunt) +- both counters reset on any successful resurrect, so a transient failure can't permanently + disable resurrecting + +Over 30 games with an undetectable NPC: **60 hunts → 4** (~40 min of waste → ~2.7 min). +Measured live: game times went 250s / 185s → **14s, 14s, 43s, 71s, 111s** once the breaker +engaged. The bot runs mercless for 15 games, then tries again. + +**Still open:** Qual-Kehk detection itself. Recapturing `qual_name_tag_white.png` is the real +fix; the breaker only stops it being expensive. --- diff --git a/src/bot.py b/src/bot.py index 31d924d..e3973ba 100644 --- a/src/bot.py +++ b/src/bot.py @@ -54,6 +54,12 @@ from utils.stealth import maybe_afk_break, should_skip_run class Bot: + # Merc-resurrect circuit breaker. A resurrect NPC that cannot be detected costs a full + # NPC hunt (~40s) per attempt, twice per game, in every game — with nothing to show for + # it. After this many consecutive failures, stop trying for a while and run mercless. + _MERC_RESURRECT_FAIL_LIMIT = 2 + _MERC_RESURRECT_SKIP_GAMES = 15 + def __init__(self, game_stats: GameStats): self._game_stats = game_stats self._messenger = Messenger() @@ -744,18 +750,38 @@ class Bot: keyboard.send("o") wait(0.2, 0.3) merc_visible = True + gs = self._game_stats + skip_until = getattr(gs, "_merc_resurrect_skip_until", 0) + if not merc_visible and gs._game_counter < skip_until: + Logger.debug( + f"Skipping merc resurrect until game {skip_until} " + f"(resurrect NPC unreachable {getattr(gs, '_merc_resurrect_fail_streak', 0)}x in a row)" + ) + merc_visible = True # suppress this game's attempt without touching state if not merc_visible and not self._game_stats._merc_resurrect_failed: Logger.info("Resurrect merc") new_loc = self._town_manager.resurrect(self._curr_loc) if new_loc is False: - # Retry once - sometimes resurrect fails due to timing - Logger.warning("Resurrect failed, retrying") - wait(0.5, 0.6) - new_loc = self._town_manager.resurrect(self._curr_loc) + # Only retry while resurrect still works sometimes. When the NPC simply + # cannot be found, the retry is a second guaranteed ~40s hunt. + if getattr(gs, "_merc_resurrect_fail_streak", 0) < 1: + Logger.warning("Resurrect failed, retrying") + wait(0.5, 0.6) + new_loc = self._town_manager.resurrect(self._curr_loc) + else: + Logger.warning("Resurrect failed — skipping the retry, this NPC has been unreachable") if new_loc is False: # Failed to resurrect (can't afford or other error) - don't log death, just continue Logger.warning("Failed to resurrect merc after retry, continuing without merc") self._game_stats._merc_resurrect_failed = True + gs._merc_resurrect_fail_streak = getattr(gs, "_merc_resurrect_fail_streak", 0) + 1 + if gs._merc_resurrect_fail_streak >= self._MERC_RESURRECT_FAIL_LIMIT: + gs._merc_resurrect_skip_until = gs._game_counter + self._MERC_RESURRECT_SKIP_GAMES + Logger.warning( + f"Merc resurrect has failed {gs._merc_resurrect_fail_streak}x in a row — " + f"skipping it until game {gs._merc_resurrect_skip_until} to stop burning " + f"~40s per hunt. Running without a merc until then." + ) # resurrect() may have traveled to A4 before failing — re-anchor breadcrumb = getattr(self._town_manager, "last_known_loc", None) if breadcrumb: @@ -768,6 +794,10 @@ class Bot: else: self._game_stats.log_merc_death() self._curr_loc = new_loc + # It worked — clear the breaker so a transient failure never + # permanently disables resurrecting. + gs._merc_resurrect_fail_streak = 0 + gs._merc_resurrect_skip_until = 0 # Gamble if needed if _maint_timed_out("gamble"): return diff --git a/src/game_stats.py b/src/game_stats.py index 408325e..5a6a865 100644 --- a/src/game_stats.py +++ b/src/game_stats.py @@ -97,6 +97,11 @@ class GameStats: self._last_status_report_run = 0 self._last_failure_reason = None self._merc_resurrect_failed = False + # Cross-game merc-resurrect circuit breaker. Qual-Kehk detection can fail 100% of + # the time (stale name-tag template -> a degenerate constant 0.424 match), and the + # per-game flag above does not stop that being retried every game at ~40s a hunt. + self._merc_resurrect_fail_streak = 0 + self._merc_resurrect_skip_until = 0 # Auto-downgrade tracking: timestamps of chickens+deaths self._downgrade_events: deque[float] = deque() os.makedirs("log/stats", exist_ok=True) @@ -271,6 +276,10 @@ class GameStats: self._game_counter += 1 self._timer = time.time() self._merc_resurrect_failed = False + # NOTE: _merc_resurrect_fail_streak / _merc_resurrect_skip_until are deliberately + # NOT reset here. They are the cross-game circuit breaker — resetting the per-game + # flag alone meant a permanently undetectable resurrect NPC was re-hunted (twice, + # ~40s each) in every single game. # Clear the previous game's failure reason so this game's events can't be # attributed to a stale reason from an earlier failure. self._last_failure_reason = None