"""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. The x-narrowing above turned out to not be the whole story either. A real run (2026-07-13, user report: "the sweep went well, but I think it overclick and closed the result page") again logged "unrecognized_state" despite a real successful MAX sweep (rows 08-12 all read correctly, sweep confirmed). Root cause this time: EVENT_SWEEP_RESULT_BUTTON_REGION's y1=700 still overlapped EVENT_SWEEP_START_BUTTON's (1400,668) own real cyan-pixel footprint (y 622-715, x 1152-1655) -- known precisely without needing a fresh live measurement, since bounty.py's own investigation into the identical bug class (its stage-info modal is confirmed pixel-identical to this one, sharing this exact button position) already measured it directly from a real screenshot. Once the real "掃討完了" result dialog is dismissed and the flow lands back on the bare stage-info modal (this function's own second `ends` condition), 掃討開始 becomes visible and cyan again -- _find_result_button re-matched its corner and clicked it, re-opening a fresh AP-usage-confirm dialog this loop had no way to recognize as anything but "still a result button showing," exactly matching the user's own diagnosis. Fixed two ways, both ported directly from bounty.py's own resolution of the identical bug: (1) EVENT_SWEEP_RESULT_BUTTON_REGION's y1 shifted from 700 to 730 (15px clear of the button's measured 715 bottom edge), kept wide through y2=1050 to still cover a possible SKIP-then-OK sequence's ~120px vertical spread, since this event's own SKIP button was never individually pixel-measured; (2) detector.find_color_centroid's min_pixels parameter (added for bounty.py's own second contamination source -- sparse stray pixels in the modal's own reward-icon artwork) applied here too via EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS, carried over from BOUNTY_RESULT_BUTTON_MIN_PIXELS by analogy rather than freshly measured against this larger region. _watch_sweep_result also gained a third `ends` condition on _is_ap_purchase_prompt (mirroring bounty.py's _is_ticket_purchase_prompt fix) as a second, independent safety layer: if a purchase prompt is ever reached here anyway, it's recognized and cancelled explicitly by _sweep_target rather than left to the blind color search. Confirmed live the very next real run: a real MAX sweep of stage 12 completed and correctly logged "result: swept" (not "unrecognized_state") for the first time. That same live run surfaced a separate, real bug in the wrong-page recovery path, reported directly by the user along with the fix: "when landed on wrong event page, it will click on the button(?) under the event. The button does nothing. Fastest way is to click event right away when get to home after returning, since ongoing event will always be on top by default, then scrolled away automatically after few ms." This overturns follow-up #5's own theory (the carousel's auto-rotate timer is SLOW, so waiting longer would eventually land on the correct event) -- the true mechanism is the opposite: the current event is the carousel's DEFAULT item immediately after the home screen is reached, and it auto-rotates away again quickly, so the fix is to click the badge as fast as possible after confirming home, not to wait for a slow timer to cycle around. The carousel's own pagination dots (EVENT_BADGE_DOT_X/_Y, originally added in follow-up #4 to "force" a specific page) don't reliably do anything either, per the user's own report, and calling them before every badge-click attempt was actively counterproductive -- extra clicks and waits that only pushed the badge-click further past the correct-by-default window. **Fix**: `_select_badge_page`/dot-clicking removed entirely; `_open_event_screen` no longer takes a `dot_index` and just clicks the badge directly; `run()`'s retry loop no longer waits `WRONG_PAGE_RETRY_WAIT` (12s, now removed) between `navigation. return_to_home` and the next `_open_event_screen` call -- it retries immediately. The report this fix is based on was the STILL-OLD dot-click/12s-wait design (the same run that confirmed the `_watch_sweep_result` fix above): it hit `wrong_page` on attempts 1-3 (all rows `None`, finished-event text confirmed each time) and only succeeded on attempt 4, after 3 full 12s waits -- the user's diagnosis of *why* it eventually worked (not because the dots did anything, but because returning home and simply trying again naturally re-lands on the correct default item) is what this fix acts on, not a live test of the fix itself. Confirmed live at zero AP cost immediately after deploying: 3 separate trials of `navigation.return_to_home` -> `_open_event_screen` (no dot click, no wait) -> checking `_is_finished_event_page`/reading the stage rows, all 3 landed on the correct current event on the very first attempt (`finished_page=False`, all 5 rows read their correct stage numbers, target stage 12 found at row 1055) -- no `wrong_page` outcome at all across all 3 trials. This confirms the navigation half of the fix directly; the full sweep-and-result path wasn't re-exercised in this same check (that needs spending real AP, deferred pending the user's own next real run). """ 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 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, min_pixels=config.EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS, ) 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 _open_event_screen(driver, config): # Per explicit user direction (2026-07-13), superseding the dot-click # approach: the carousel's pagination dots don't reliably do anything # ("it will click on the button under the event. The button does # nothing"), and the earlier "wait long enough for the slow auto-rotate # timer to cycle back to the correct event" theory had it backwards -- # the current/ongoing event is the DEFAULT item shown the moment the # home screen is reached, and it auto-rotates AWAY within a very short # window afterward. The fix is to click the badge as immediately as # possible after confirming home, not to wait -- see run()'s own retry # loop, which no longer inserts a wait between navigation.return_to_home # and the next _open_event_screen call for exactly this reason. 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. # # A THIRD ends condition, added 2026-07-13 after a real user report # ("the sweep went well, but I think it overclick and closed the # result page"): EVENT_SWEEP_RESULT_BUTTON_REGION used to overlap # EVENT_SWEEP_START_BUTTON's own real footprint (see that config # constant's comment), so once the real result dialog was dismissed and # the bare stage-info modal reappeared, _find_result_button could # re-match 掃討開始 itself and click it -- re-opening a fresh AP-usage- # confirm dialog this loop had no way to recognize as anything but # "still a result button." The region is now fixed to exclude that # button's footprint, but this explicit check (mirroring bounty.py's # own identical `_is_ticket_purchase_prompt` fix for the same bug # class) is a second, independent layer: if a purchase/insufficient-AP # prompt is ever reached here anyway, for any reason, it's recognized # and handled explicitly by the caller rather than left to a blind # color search that could otherwise re-click into it. 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_ap_purchase_prompt(d, c)): "prompted_to_purchase", (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 outcome == "prompted_to_purchase": # See _watch_sweep_result's own comment -- cancel this dialog # explicitly rather than let _close_stage_modal's plain Escape loop # be the only thing standing between it and a real AP purchase. print("[event_sweep] AP-purchase prompt detected after the sweep -- cancelling without purchasing") driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) driver.wait(1) 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() ap = navigation.current_ap(driver) if ap is not None and ap < config.SWEEP_MIN_AP: print(f"[event_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do") return 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): # No wait between reaching home and clicking the badge -- see # _open_event_screen's own docstring for why: the current event is # the carousel's default item right after landing on home, and it # rotates away quickly, so any delay here (the original design # waited WRONG_PAGE_RETRY_WAIT=12s, exactly backwards) just means # missing the correct item before the click even happens. navigation.return_to_home(driver) if not _open_event_screen(driver, config): print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES})") 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}) -- retrying immediately") 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.")