"""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. """ import datetime from ba_auto import detector, navigation OPEN_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 6 MAX_BUTTON_RETRIES = 3 MODAL_CLOSE_RETRIES = 3 WRONG_PAGE_RETRIES = 3 WRONG_PAGE_RETRY_WAIT = 3 STAGE_ENTER_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 _open_event_screen(driver, config): 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 _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 -- a finished/ # stale event's Quest tab shows plain "period ended" text instead of # 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." 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: return row_y, saw_any_valid_row return None, saw_any_valid_row 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): def click_result_button(d): pos = _find_result_button(d, config) if pos: d.click(*pos) d.wait(1.5) ends = { (lambda d, c: _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 _sweep_target(driver, config, stage, count): print(f"[event_sweep] --- target stage {stage} x {count} ---") row_y, saw_any_valid_row = _find_stage_row(driver, config, stage) if row_y is None: if not saw_any_valid_row: print("[event_sweep] no stage-row numbers recognized at all -- likely on the wrong/stale event page") 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" driver.click(*config.EVENT_SWEEP_START_BUTTON) driver.wait(1.5) 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" 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") 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): if not _open_event_screen(driver, config): print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES})") 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}) -- 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.")