"""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. Stage 08/09 OCR misread (2026-07-11, previously flagged in plan.md as a known-but-non-blocking gap, then hit for real once the daily rotation picked stage 9): row @366 ("08") read as "2", row @538 ("09") read as empty/None, on every psm mode tried with plain detector.read_int, even though the crop and its thresholded/upscaled version both looked perfectly clean by eye (scratchpad/probe_event_stage_ocr.py, probe_ocr_psm_sweep.py) -- confirmed live NOT a navigation/timing bug this time, since rows 710/883/1055 ("10"/"11"/"12") read correctly in the same run. Root cause, isolated via scratchpad/probe_ocr_fix_08_09.py: tesseract's segmentation struggles with this specific tight edge-to-edge crop (no surrounding whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both "08" and "09" to their exact correct values at every psm mode tried, without affecting the already-working "10". Ported as detector.read_int_bordered, now used for all EVENT_STAGE_ROW_Y reads in _scan_stage_rows_once. The same live run also confirmed the sweep itself succeeded end-to-end (AP 233->14, credits +5,892, screenshot-confirmed safe return home) but still logged "unrecognized_state" instead of "swept" -- the _watch_sweep_result outcome-logging bug flagged after Phase 14 follow-up #5 as "not yet re-verified live" was, it turns out, still broken, just for a DIFFERENT reason than the POST_SWEEP_DISMISS_ROUNDS timing budget that fix targeted. Root cause this time, isolated with zero AP cost via scratchpad/probe_result_button_fp.py (checks _find_result_button against the plain Quest list with no sweep in progress): the shared SWEEP_RESULT_BUTTON_REGION (700-1300 x-range, borrowed directly from story_sweep.py) reaches into this event's own Quest-list character-art panel on the left side of the screen, which false-positive-matched SWEEP_CONFIRM_CYAN even with no result dialog showing at all -- confirmed on both a wrong/finished event page and, more importantly, the actual correct current event's own plain list. This meant _watch_sweep_result's reaction kept "finding" a result button and clicking it after the real one had already been fully dismissed, so the "modal closed, no result button" end condition could never match. Fixed with a new, event_sweep- only EVENT_SWEEP_RESULT_BUTTON_REGION (x narrowed to 1000-1300, excluding the character-art panel while still comfortably covering the real buttons' known x~1150 position) -- confirmed via the same saved false-positive screenshot returning no match afterward. Deliberately NOT changed in the shared SWEEP_RESULT_BUTTON_REGION story_sweep.py also uses, per this file's own established pattern of giving event_sweep its own constant rather than coupling the two tasks' config together whenever their actual on-screen content differs (see EVENT_STAGE_MODAL_PROBE's comment for the earlier instance of the same reasoning). Not yet re-confirmed against a fresh real bulk sweep (that day's AP was reduced to 14/240 by the sweep that surfaced this, too low to force another MAX sweep) -- the fix is validated against the actual recorded false-positive screenshot and the same live code path, just not a full new live sweep. """ 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.EVENT_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_bordered(_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.")