fix(merc): stop re-hunting an undetectable resurrect NPC every game (~80s each)
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 over
12 games). Each failed hunt costs ~40s and the code retried once, so a dead merc cost
~80s in EVERY game. Game length blew out to 185-250s against a normal ~60s.
GameStats._merc_resurrect_failed did not help: log_start_game resets it, so it only ever
suppressed a second attempt within one game. Nothing carried across games.
The name tag template is degenerate rather than merely stale — every grid-sweep "hit"
reported the identical score at unrelated positions:
found name tag at (255, 227) (score 0.424)
found name tag at (1110, 100) (score 0.424)
found name tag at (930, 310) (score 0.424)
so "found" is meaningless; it is matching uniform background.
Fix is cost containment, not detection: a cross-game circuit breaker on GameStats that
log_start_game deliberately does NOT reset — _merc_resurrect_fail_streak and
_merc_resurrect_skip_until, with Bot._MERC_RESURRECT_FAIL_LIMIT=2 and
_MERC_RESURRECT_SKIP_GAMES=15. The retry is also skipped once the streak is >=1, since
that is a second guaranteed-futile 40s hunt. Both counters clear on any successful
resurrect so a transient failure cannot permanently disable resurrecting.
Simulated over 30 games with an undetectable NPC: 60 hunts -> 4 (~40 min -> ~2.7 min).
Measured live: the breaker engaged on game 2 and game times went 250s / 185s -> 14s, 14s,
43s, 71s, 111s.
Still open: recapturing qual_name_tag_white.png is the actual fix for detection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
41
CLAUDE.md
41
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
38
src/bot.py
38
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user