ba-auto-daily/ba_auto/tasks/event_sweep.py
Nik Afiq 568e17d244 Refactor event sweep and arena tasks; enhance OCR and navigation reliability
- Updated `mapping.md` to reflect completed migration and testing status for event sweep and arena tasks.
- Improved `arena.py` to ensure a return to home screen before opening tactical challenges, preventing navigation errors.
- Enhanced `event_sweep.py` with robust handling for badge carousel navigation, including direct pagination dot clicks and extended wait times for cold-start scenarios.
- Implemented Japanese OCR support for finished event detection in `event_sweep.py`, adding a definitive check to avoid false positives on stale event pages.
- Adjusted retry logic and timeouts in `event_sweep.py` to accommodate longer loading times and ensure accurate stage row detection.
- Updated `setup.sh` to check for the presence of the Japanese OCR language pack, providing installation instructions if missing.
- Documented live testing results and fixes in `plan.md`, confirming successful sweeps and addressing previously reported bugs.
2026-07-11 00:04:50 +09:00

531 lines
26 KiB
Python

"""Event AP sweep. Reference: baas-reference/module/sweep_activity.py and
module/activities/activity_utils.py's activity_sweep/start_sweep.
Ported per explicit user direction (2026-07-10): the currently-running event
("鉄道爆走事件") exposes 12 stages via a 任務情報 (task info) modal that is
structurally identical to story_sweep.py's own stage modal (MIN/-/+/MAX
count stepper, the same AP-usage confirm dialog, the same 掃討完了 SKIP/OK
result screen) -- see config.py's "Event sweep" section for the full live
calibration writeup. Rather than porting the reference's config-string
sweep-list parsing (arbitrary stage lists with per-stage float/fraction
counts), this sweeps exactly one stage per run, chosen from a fixed 9-12
sub-range via the same date-ordinal-modulo rotation story_sweep.py already
uses for its own daily-rotating target.
Key differences from story_sweep.py, all confirmed live during calibration:
- No region concept/navigation -- one flat stage list, reached via the home
screen's event badge -> Quest tab, not story_sweep's Work-hub card ->
region browser.
- The stage list only ever needs its bottom scroll extreme, which always
reveals stages 9-12 (this event's last 5 stages) regardless of starting
scroll position -- no separate top/bottom row-position sets needed.
- Row height is constant regardless of 1-line vs 2-line title wrapping,
unlike story_sweep's stage list.
- The stage modal has ONE fixed layout (confirmed against both stage 09 and
12), no tabbed-vs-plain variant to account for.
- The stage modal DOES close on Escape, unlike story_sweep's (X-button-only).
- Both stages tested were already 3-starred; the reference's SSS-availability
gate for never-cleared stages was never actually exercised (see config.py).
Live-testing after initial calibration found the home screen's event badge
(config.EVENT_BADGE_ICON) is itself a rotating carousel, not a stable
single-event slot as calibration happened to suggest: it cycles between the
current event's countdown AND other notices (e.g. an already-finished
event's remaining reward-claim-period reminder), so a single click can land
on a stale/wrong event page instead of the current one. _find_stage_row now
distinguishes "wrong page entirely" (no stage-row numbers OCR at all) from
"right page, this specific stage just isn't there" (some numbers found, just
not the target) -- the former retries via navigation.return_to_home + a
re-click of the badge, hoping the carousel has moved on by the next attempt;
the latter is reported and left alone, since retrying won't fix a genuinely
different stage list.
A second live run (after the above fix) correctly recovered from a wrong
first attempt and correctly OCR'd the target stage's row number, but then
failed to open that stage's info modal: the row-enter click was a single
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.
A fourth run (this time self-driven live-testing, not a user report) found
the wrong-page problem again, but WORSE: all 3 outer attempts landed on the
wrong page, back to back, with no false-positive settling issue this time --
the badge carousel was just genuinely sitting on the wrong item (a finished
event's reward-claim reminder) for the entire run, confirmed by screenshot.
Investigating live found the carousel's own small pagination dots (below
the badge thumbnail) are directly clickable and immediately switch pages,
rather than needing to wait for its auto-rotate timer (which is far slower
than this task's retry window -- confirmed still showing the wrong item
20-30+ seconds later). _open_event_screen now clicks a specific dot
(config.EVENT_BADGE_DOT_X) BEFORE each badge-open attempt, cycling through
dot positions across run()'s outer retry loop, instead of repeatedly
clicking the ambiguous badge itself and hoping the timer has moved on.
Even with the dot fix, a fifth run (still self-driven) STILL read all 5
rows as None on every attempt. Live diagnosis (standalone probe scripts
importing this project's own driver/detector code directly, run via SSH)
found the dot-click + badge-open navigation was actually landing correctly
every time -- the remaining problem was pure timing: after a long-idle
cold start, the Quest tab's stage list can take FAR longer to actually
populate than assumed (empirically, up to ~20 seconds from a genuinely
cold start, vs. the ~3.5s total budget STAGE_ROW_SCAN_ATTEMPTS/
_RETRY_WAIT gave it), most likely a server round-trip the client only pays
on the first open in a session. A polling probe confirmed reads stabilize
and stay stable for a full minute-plus once they succeed -- this was never
a flaky/intermittent render glitch, just a budget that was too short for a
cold start specifically. STAGE_ROW_SCAN_ATTEMPTS/_RETRY_WAIT were widened
to a ~20s total budget to match. The immediate next live run (same
session) went all the way through: found stage 12's row, opened the
modal, raised the count to MAX, clicked 掃討開始, confirmed the AP-usage
dialog, and a REAL 10x sweep executed -- 200 AP spent (206 -> 6, matching
the calibrated MAX count exactly at that AP level) and credits increased,
confirmed by screenshot. The one remaining wrinkle: _watch_sweep_result's
own polling budget (POST_SWEEP_DISMISS_ROUNDS, originally 6 iterations x
1.5s = 9s) was too short for a 10x bulk sweep's longer reward-reveal
sequence, so the outcome was logged as "unrecognized_state" instead of
"swept" even though the sweep itself succeeded and _close_stage_modal's
fallback safely recovered the screen back to home afterward. Widened to
14 iterations to match the same cold-start-budget lesson, though this
specific fix hasn't been re-confirmed live (that day's AP was fully spent
by the successful sweep above, leaving none for a further live test).
A sixth run, a day later (fresh AP), went right back to the original
symptom: all 5 rows None across the full 9-scan/~20s budget, on both
outer attempts shown in the user's log before it was interrupted. Per the
user's own diagnosis -- check for the finished event's own "イベント期間が
終了しました" text directly, rather than inferring "wrong page" from empty
rows alone -- Japanese OCR support (tesseract-ocr-jpn) was installed
(previously only "eng" was available; installing needs interactive sudo,
which the user did directly).
Live investigation to pin down the exact text region initially hit a wall
-- repeated attempts to reproduce a wrong/finished event page (clicking
every known badge-carousel dot position, the bottom-left banner, the
event-story replay archive) kept landing back on the CURRENT correct
event instead, suggesting run #4's original "carousel shows the wrong
item" diagnosis might not be reliably reproducible on demand. Eventually
reproduced it anyway (clicking the badge while it happened to be
displaying "嵐過天晴"'s reward-claim-period notice, same as run #4) and
used it to calibrate for real: EVENT_FINISHED_TEXT_RECT's OCR read the
exact phrase "イベント期間が終了しました。" verbatim via lang="jpn", and a
follow-up check on the correct event's own page read unrelated stage-list
text with no false-positive "終了" match -- both directions live-confirmed,
not guessed. _is_finished_event_page is used as an early-exit,
authoritative-when-positive check inside _find_stage_row's scan loop: a
positive match ends the wait immediately as a confirmed wrong page; a
negative match does NOT prove the page is right (a different finished
event's layout might differ), so patient rescanning still continues
regardless either way up to the existing budget. If that budget is ever
fully exhausted with no rows and no confirmed finished-page text, a debug
screenshot is saved to scratchpad/ (event_sweep_no_rows_debug.png) for
further diagnosis -- that residual "inconclusive" case is still treated
as "wrong_page" for recovery purposes (return home, retry with a
different badge-carousel dot), since sitting stuck indefinitely isn't
better than an unnecessary retry.
The very next live run with the finished-text check deployed confirmed it
works exactly as designed -- every one of the 3 outer attempts correctly
and immediately identified "finished-event page text detected" instead of
wasting the full ~20s rescan budget on each, a big win for diagnostic
clarity. But it also revealed the fix's real limit: all 3 attempts landed
on the SAME wrong page. Manual live investigation right after found the
badge carousel's dot-click behavior is NOT a reliable way to force a
specific page after all -- clicking a dot sometimes visibly switched the
badge's content (as run #4/#6 first found) and sometimes did nothing at
all (confirmed back-to-back on the same badge state), while simply
waiting was independently observed to eventually cycle the badge back to
the correct event on its own. This points to a genuine time-based
auto-rotate timer as the real mechanism, with dot-clicking being at best
an unreliable nudge on top of it, not a deterministic override. The Work
hub (お仕事, a stable non-carousel entry point already used by story_sweep/
arena) was also checked as a possible alternative and does NOT have a
dedicated card for this event, so the badge remains the only viable entry
point. Given the timer is the real mechanism, WRONG_PAGE_RETRIES/_WAIT
were widened (3 attempts x 3s -> 6 attempts x 12s, ~72s total) to give the
natural rotation a real chance to land on the correct item within the
retry window, rather than relying on a fast-but-unreliable dot-click to
force it. Dot-clicking is kept as a harmless best-effort nudge alongside
the longer wait, not removed, since it did visibly work at least twice.
"""
import datetime
import os
from ba_auto import detector, navigation
OPEN_RETRIES = 3
POST_SWEEP_DISMISS_ROUNDS = 14
MAX_BUTTON_RETRIES = 3
MODAL_CLOSE_RETRIES = 3
WRONG_PAGE_RETRIES = 6
WRONG_PAGE_RETRY_WAIT = 12
STAGE_ENTER_RETRIES = 3
STAGE_ROW_SCAN_ATTEMPTS = 9
STAGE_ROW_SCAN_RETRY_WAIT = 2.5
SWEEP_START_RETRIES = 3
def _is_stage_modal_open(driver, config):
r, g, b = driver.color_at(*config.EVENT_STAGE_MODAL_PROBE)
return r < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL
def _color_in_range(rgb, rgb_range):
lo, hi = rgb_range
r, g, b = rgb
return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2]
def _is_sweep_usage_confirm(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN)
def _is_ap_purchase_prompt(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD)
def _find_result_button(driver, config):
return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN)
def _count_raised_above_one(driver, config):
# This modal's "-" stepper turns a saturated coral/orange once raised,
# a subtler shift than story_sweep's vivid-orange indicator -- told
# apart from its flat-grey default by color *spread* (max-min channel)
# rather than a fixed g/b ceiling. See config.py's EVENT_SWEEP_MINUS_
# BUTTON_PROBE comment.
r, g, b = driver.color_at(*config.EVENT_SWEEP_MINUS_BUTTON_PROBE)
return (max(r, g, b) - min(r, g, b)) > 40
def _select_badge_page(driver, config, dot_index):
dot_xs = config.EVENT_BADGE_DOT_X
if dot_index >= len(dot_xs):
return # no more known dot positions -- fall through to a bare badge click
driver.click(dot_xs[dot_index], config.EVENT_BADGE_DOT_Y)
driver.wait(0.5)
def _open_event_screen(driver, config, dot_index=0):
# Force the carousel to a specific known page before clicking into it,
# rather than clicking the ambiguous badge directly and hoping it
# currently shows the right event (see config.py's EVENT_BADGE_DOT_*
# comment -- confirmed live the auto-rotate timer is far slower than
# this task's retry window).
_select_badge_page(driver, config, dot_index)
for attempt in range(1, OPEN_RETRIES + 1):
driver.click(*config.EVENT_BADGE_ICON)
driver.wait(2)
if navigation.is_on_subscreen(driver):
break
print(f"[event_sweep] event screen not detected after click (attempt {attempt}/{OPEN_RETRIES})")
else:
return False
driver.click(*config.EVENT_QUEST_TAB)
driver.wait(1)
return True
def _row_number_rect(config, row_y):
x1, x2 = config.EVENT_STAGE_NUMBER_OCR_X
top_pad, bottom_pad = config.EVENT_STAGE_NUMBER_OCR_Y_PAD
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 _is_finished_event_page(driver, config):
# Direct, definitive check for a finished/stale event's own "イベント
#期間が終了しました。" (event period has ended) text, per explicit user
# request after repeated false "wrong page" loops -- see config.py's
# EVENT_FINISHED_TEXT_RECT comment for the full story, including that
# this rect is a best-effort estimate, not yet live-confirmed against a
# real capture of this exact text. Matches on the substring "終了"
# rather than the full phrase, since that's more tolerant of OCR noise
# while still being distinctive vocabulary within this screen -- the
# stage list's own text (stage names, "入場", star counts) never
# contains it.
text = detector.read_text(config.EVENT_FINISHED_TEXT_RECT, psm=6, lang="jpn")
return "終了" in text
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
# story_sweep._find_stage_row, only the bottom extreme is ever checked.
#
# Also tracks whether ANY row OCR'd a real number at all. Originally,
# all-empty reads alone were treated as "wrong page" -- live testing
# found that produces false positives (a fresh navigation's list can
# still be settling/rendering for far longer than expected, up to
# ~20s+), and false negatives are cheap to avoid: an explicit check for
# the finished-event page's own "period ended" text (_is_finished_event_
# page) is the AUTHORITATIVE signal now. All-empty reads alone just
# mean "keep waiting patiently", not "wrong page" -- see module
# docstring for the full history of this getting fixed twice.
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
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, False
if _is_finished_event_page(driver, config):
print("[event_sweep] finished-event page text detected -- confirmed wrong page")
return None, False, True
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)
# Exhausted the patience budget with no valid rows AND no confirmed
# finished-event text -- a genuinely inconclusive state. Save a debug
# screenshot so a future recurrence can actually be diagnosed/used to
# fix EVENT_FINISHED_TEXT_RECT's calibration, per CLAUDE.md's "write
# debug images to scratchpad/" convention.
debug_path = os.path.join(config.SCRATCHPAD_DIR, "event_sweep_no_rows_debug.png")
driver.screenshot(debug_path)
print(f"[event_sweep] gave up waiting for stage rows without confirming finished-event text either -- saved {debug_path}")
return None, False, False
def _click_max_and_verify(driver, config):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
driver.click(*config.EVENT_SWEEP_MAX_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[event_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _click_plus_and_verify(driver, config, count):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
for _ in range(count - 1):
driver.click(*config.EVENT_SWEEP_PLUS_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[event_sweep] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _set_sweep_count(driver, config, count):
if count == "max":
return _click_max_and_verify(driver, config)
return _click_plus_and_verify(driver, config, count)
def _close_stage_modal(driver, config):
# Confirmed live this modal DOES close on Escape (unlike story_sweep's,
# which needs its own X button) -- try Escape first, fall back to the X
# button if it somehow doesn't clear.
for _ in range(MODAL_CLOSE_RETRIES):
if not _is_stage_modal_open(driver, config):
return True
driver.keypress("Escape")
driver.wait(1)
if not _is_stage_modal_open(driver, config):
return True
driver.click(*config.EVENT_STAGE_MODAL_CLOSE_BUTTON)
driver.wait(1)
return not _is_stage_modal_open(driver, config)
def _watch_sweep_result(driver, config):
# Confirmed live twice now (both real 10x/11x bulk sweeps): after the
# last result-screen button (SKIP then a final OK) is clicked, the game
# can land all the way back on the underlying Quest LIST rather than
# the bare stage-info modal this was originally calibrated against
# (story_sweep.py's own equivalent screen always returns to its stage
# modal, which is why the "ends" check here originally required it) --
# both real sweeps succeeded (AP spent, credits gained, confirmed by
# screenshot) but this "ends" check never matched, exhausting the full
# POST_SWEEP_DISMISS_ROUNDS budget regardless of size and falling
# through to "unrecognized_state" even though the sweep genuinely
# completed. `clicked_any` gates the modal-closed branch on having
# clicked at least one result button first, so an immediate "no result
# button visible yet" read on the very first check (before the
# SKIP/OK sequence has even started) still can't be mistaken for
# "swept" -- only "modal gone after we've actually clicked through
# something" counts.
clicked_any = {"value": False}
def click_result_button(d):
pos = _find_result_button(d, config)
if pos:
d.click(*pos)
clicked_any["value"] = True
d.wait(1.5)
ends = {
(lambda d, c: _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
(lambda d, c: clicked_any["value"] and not _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
}
reactions = {
(lambda d, c: _find_result_button(d, c) is not None): click_result_button,
}
outcome = navigation.wait_for_state(
driver, config, reactions, ends,
max_iterations=POST_SWEEP_DISMISS_ROUNDS, poll_interval=1.5,
)
return outcome or "unrecognized_state"
def _open_stage_modal(driver, config, row_y):
# A single click-then-check here was found live to intermittently miss
# (CLAUDE.md already documents this exact failure mode for the
# mailbox/cafe icons: "missed the first click and worked on retry") --
# retry-with-verify like every other click-then-confirm step in this
# module, rather than aborting on the first miss.
for attempt in range(1, STAGE_ENTER_RETRIES + 1):
driver.click(config.EVENT_STAGE_ENTER_X, row_y)
driver.wait(2)
if _is_stage_modal_open(driver, config):
return True
print(f"[event_sweep] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})")
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} ---")
row_y, saw_any_valid_row, confirmed_wrong_page = _find_stage_row(driver, config, stage)
if row_y is None:
if confirmed_wrong_page:
return "wrong_page"
if not saw_any_valid_row:
print("[event_sweep] gave up waiting for stage rows without ever confirming finished-event text -- treating as wrong page anyway (see scratchpad debug screenshot)")
return "wrong_page"
print(f"[event_sweep] stage {stage} not found in the visible stage list")
return "stage_not_found"
if not _open_stage_modal(driver, config, row_y):
print("[event_sweep] stage info panel not detected, aborting")
return "unrecognized_state"
if not _set_sweep_count(driver, config, count):
print("[event_sweep] could not confirm sweep count was raised (stage may not be SSS-cleared/sweepable yet) -- aborting without spending AP")
_close_stage_modal(driver, config)
return "not_sweepable"
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")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
_close_stage_modal(driver, config)
return "inadequate_ap"
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1.5)
print("[event_sweep] sweep confirmed, waiting for results")
outcome = _watch_sweep_result(driver, config)
print(f"[event_sweep] result: {outcome}")
if not _close_stage_modal(driver, config):
print("[event_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
return outcome
def _rotation_target(config):
stage_min = getattr(config, "EVENT_SWEEP_ROTATION_STAGE_MIN", None)
if not stage_min:
return None
stage_max = config.EVENT_SWEEP_ROTATION_STAGE_MAX
span = stage_max - stage_min + 1
stage = stage_min + (datetime.date.today().toordinal() % span)
return (stage, config.EVENT_SWEEP_ROTATION_COUNT)
def run(driver, config):
driver.focus_game()
rotation = _rotation_target(config)
if not rotation:
print("[event_sweep] no rotation target configured (config.EVENT_SWEEP_ROTATION_STAGE_MIN is unset), nothing to do")
return
stage, count = rotation
print(f"[event_sweep] today's rotation target: stage {stage}")
outcome = None
for attempt in range(1, WRONG_PAGE_RETRIES + 1):
dot_index = attempt - 1
if not _open_event_screen(driver, config, dot_index):
print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES}, badge page {dot_index})")
navigation.return_to_home(driver)
driver.wait(WRONG_PAGE_RETRY_WAIT)
continue
outcome = _sweep_target(driver, config, stage, count)
if outcome == "wrong_page":
print(f"[event_sweep] landed on the wrong event page (attempt {attempt}/{WRONG_PAGE_RETRIES}, badge page {dot_index}) -- returning home to retry")
navigation.return_to_home(driver)
driver.wait(WRONG_PAGE_RETRY_WAIT)
continue
break
else:
print(f"[event_sweep] could not reach the current event's stage list after {WRONG_PAGE_RETRIES} attempts, giving up")
if outcome is not None and outcome not in ("swept", "wrong_page"):
print(f"[event_sweep] target stage {stage} ended in '{outcome}'")
if not navigation.return_to_home(driver):
print("[event_sweep] warning: could not confirm return to home screen")
print("[event_sweep] Done.")