fix(event_sweep): enhance error handling with retries for stage-row detection and sweep start click

This commit is contained in:
Nik Afiq 2026-07-10 12:50:34 +09:00
parent 63ee47d6e7
commit 33dd375f36
3 changed files with 79 additions and 16 deletions

View File

@ -8,7 +8,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...`
| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect` | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range``driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger |
| Stamina/AP | `module/collect_daily_free_power.py`, `module/collect_daily_task_power.py` | `to_tasks`/`implement` (task-power, ported); `to_purchase_pyroxenes_menu` (free-power, not ported) | `ba_auto/tasks/stamina.py` | `color.rgb_in_range``driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Partially migrated: Mission-panel claim done (see `plan.md` Phase 8). Daily Free Power (real-money purchase menu) deliberately not automated |
| Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow |
| Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. Neither fix has been live-tested against a real recurrence yet. |
| Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. None of these three fixes has been live-tested against a real recurrence yet. |
| Group/Club AP | `module/group.py` | Need to inspect | `ba_auto/tasks/group.py` | fixed click + state check via local driver | Not started |
| Bounty | `module/rewarded_task.py` | Need to inspect | `ba_auto/tasks/bounty.py` | sweep/color/OCR adaptation | Not started |
| Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started |

View File

@ -47,6 +47,26 @@ click-then-check with no retry, unlike every other click-then-confirm step
in this module. _open_stage_modal now retries it the same way, matching
CLAUDE.md's own documented history of the mailbox/cafe icons missing their
first click and working on retry.
A third live run surfaced two more instances of the same underlying
problem class:
1. _find_stage_row's "wrong page" detection (all 5 rows OCR empty) fired
as a FALSE POSITIVE twice in a row on a run that was actually on the
right page the whole time -- the Quest tab's stage list evidently
hadn't finished rendering yet on the first OCR pass right after
navigating there fresh, and 1 second wasn't always enough settle time.
Burning a full return-home-and-re-navigate cycle (the "wrong page"
recovery path) on a pure rendering race wastes retry budget that should
be reserved for an actually-wrong page. _find_stage_row now does a
cheap in-place rescan (re-read the same rows, no re-scroll/re-navigate)
a couple of times before concluding "no valid rows at all."
2. Once past that, the MAX-count-raise succeeded (on its own internal
retry) but the following 掃討開始 (start sweep) click was, again, a bare
single click with no retry -- the click missed, no confirm dialog ever
appeared, and the whole run aborted right at the last step before
actually spending AP. _click_sweep_start_and_verify now wraps it the
same way as every other click in this file.
"""
import datetime
@ -60,6 +80,9 @@ MODAL_CLOSE_RETRIES = 3
WRONG_PAGE_RETRIES = 3
WRONG_PAGE_RETRY_WAIT = 3
STAGE_ENTER_RETRIES = 3
STAGE_ROW_SCAN_ATTEMPTS = 3
STAGE_ROW_SCAN_RETRY_WAIT = 1.5
SWEEP_START_RETRIES = 3
def _is_stage_modal_open(driver, config):
@ -116,6 +139,18 @@ def _row_number_rect(config, row_y):
return (x1, row_y - top_pad, x2, row_y + bottom_pad)
def _scan_stage_rows_once(driver, config, stage):
saw_any_valid_row = False
for row_y in config.EVENT_STAGE_ROW_Y:
label = detector.read_int(_row_number_rect(config, row_y))
print(f"[event_sweep] row @ {row_y}: read '{label}'")
if label is not None:
saw_any_valid_row = True
if label == stage:
return row_y, saw_any_valid_row
return None, saw_any_valid_row
def _find_stage_row(driver, config, stage):
# This event's target range (9-12) always sits within the last 5 rows,
# confirmed live regardless of starting scroll position -- so unlike
@ -126,19 +161,26 @@ def _find_stage_row(driver, config, stage):
# stage-row cards, so all 5 reads coming back empty is a strong signal
# we're on the wrong page entirely (see module docstring), distinct from
# "right page, this stage just isn't among the visible rows."
#
# A fresh navigation's list can still be settling/rendering when the
# first OCR pass runs -- confirmed live, this produced two consecutive
# false-positive "wrong page" reads on a run that was actually on the
# right page the whole time. Rescan in place (no re-scroll/re-navigate)
# a couple of times before concluding "no valid rows at all" -- cheap
# compared to the caller's much more expensive return-home-and-retry
# recovery, which should be reserved for an actually-wrong page.
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
saw_any_valid_row = False
for row_y in config.EVENT_STAGE_ROW_Y:
label = detector.read_int(_row_number_rect(config, row_y))
print(f"[event_sweep] row @ {row_y}: read '{label}'")
if label is not None:
saw_any_valid_row = True
if label == stage:
for attempt in range(1, STAGE_ROW_SCAN_ATTEMPTS + 1):
row_y, saw_any_valid_row = _scan_stage_rows_once(driver, config, stage)
if row_y is not None or saw_any_valid_row:
return row_y, saw_any_valid_row
return None, saw_any_valid_row
print(f"[event_sweep] no stage-row numbers recognized on scan {attempt}/{STAGE_ROW_SCAN_ATTEMPTS} -- screen may still be settling")
if attempt < STAGE_ROW_SCAN_ATTEMPTS:
driver.wait(STAGE_ROW_SCAN_RETRY_WAIT)
return None, False
def _click_max_and_verify(driver, config):
@ -219,6 +261,21 @@ def _open_stage_modal(driver, config, row_y):
return False
def _click_sweep_start_and_verify(driver, config):
# Same missed-click failure class as _open_stage_modal -- a bare single
# click here was found live to leave neither the AP-usage-confirm nor
# the AP-purchase dialog detected, aborting the run at the very last
# step before it would have actually spent AP. Retry-with-verify like
# every other click in this module.
for attempt in range(1, SWEEP_START_RETRIES + 1):
driver.click(*config.EVENT_SWEEP_START_BUTTON)
driver.wait(1.5)
if _is_sweep_usage_confirm(driver, config) or _is_ap_purchase_prompt(driver, config):
return True
print(f"[event_sweep] sweep confirm/AP-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})")
return False
def _sweep_target(driver, config, stage, count):
print(f"[event_sweep] --- target stage {stage} x {count} ---")
@ -239,8 +296,10 @@ def _sweep_target(driver, config, stage, count):
_close_stage_modal(driver, config)
return "not_sweepable"
driver.click(*config.EVENT_SWEEP_START_BUTTON)
driver.wait(1.5)
if not _click_sweep_start_and_verify(driver, config):
print("[event_sweep] sweep-usage confirmation not detected, aborting without further input")
_close_stage_modal(driver, config)
return "unrecognized_state"
if _is_ap_purchase_prompt(driver, config):
print("[event_sweep] insufficient AP for this sweep -- cancelling without purchasing")
@ -249,11 +308,6 @@ def _sweep_target(driver, config, stage, count):
_close_stage_modal(driver, config)
return "inadequate_ap"
if not _is_sweep_usage_confirm(driver, config):
print("[event_sweep] sweep-usage confirmation not detected, aborting without further input")
_close_stage_modal(driver, config)
return "unrecognized_state"
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1.5)
print("[event_sweep] sweep confirmed, waiting for results")

View File

@ -503,6 +503,15 @@ This is the same failure class `CLAUDE.md` already documents for the mailbox/caf
**Not yet live-tested**: syntax/compile-checked and deployed only.
#### Phase 14 follow-up #3: false-positive wrong-page detection + one more missing retry
**Reported by the user after a third real run.** Two more instances of the exact same problem class surfaced in one log:
1. **`_find_stage_row`'s wrong-page detection false-positived twice in a row** on a run that turned out to be on the right page the whole time. The Quest tab's stage list evidently hadn't finished rendering on the first OCR pass right after a fresh navigation (all 5 rows OCR'd empty), and the existing 1-second settle wait wasn't always enough. Since "wrong page" triggers the *expensive* recovery path (`navigation.return_to_home` + a full re-navigate, ~3+ seconds each), this wasted 2 of the outer loop's 3 attempts on what was actually just a rendering race, before the 3rd attempt (now naturally a bit later, giving the UI more time to settle) read all 5 rows correctly. Fixed by adding a *cheap* in-place rescan inside `_find_stage_row` itself (`STAGE_ROW_SCAN_ATTEMPTS = 3`, re-reading the same rows with a short wait between, no re-scroll/re-navigate) before concluding "no valid rows at all" — reserving the expensive return-home recovery for a genuinely wrong page, not a timing race.
2. **Once past that, `_click_max_and_verify` correctly raised the sweep count to MAX (on its own internal retry) but the very next click — 掃討開始 (start sweep) — was still a bare single click with no retry**, the last unguarded click in the whole file. It missed once, no confirm dialog (neither the AP-usage-confirm nor the AP-purchase variant) was ever detected, and the run aborted right before it would have actually spent AP. Fixed via new `_click_sweep_start_and_verify`, the same click-then-verify-then-retry pattern (`SWEEP_START_RETRIES = 3`) as every other click in this file — checking for *either* dialog variant, since which one appears depends on current AP.
**Not yet live-tested**: syntax/compile-checked and deployed only. Every click in `_sweep_target`'s critical path now follows the same retry pattern; if a future run still fails, the next place to look is `SWEEP_CONFIRM_BUTTON`'s own click (the actual AP-spend-committing click) or `_watch_sweep_result`'s polling loop, the two remaining steps that haven't yet been individually implicated by a real failure.
## Prerequisites
### OCR