"""Normal story AP sweep. Reference: baas-reference/module/explore_tasks/sweep_task.py and task_utils.py. Ported per CLAUDE.md's "OCR policy" and Handoff.md: sweeps a config-driven list of exact (region, stage, count) targets (config.STORY_SWEEP_TARGETS), navigating to each one deterministically instead of the previous "latest unlocked region, then a random stage" heuristic -- see plan.md Phase 9's retrospective for why that heuristic was a mistake. - `_go_to_region` ports task_utils.py::to_region: OCR the current region number, click the exact delta, re-check, bounded loop. - `_find_stage_row` is a scoped-down port of core/image.py's swipe_search_target_str: OCR each visible stage row's label and match it against the configured target, rather than grabbing a random row. - `_watch_sweep_result` is built on navigation.wait_for_state, this project's scoped port of core/picture.py::co_detect, and returns a named outcome ("swept", "inadequate_ap", "unrecognized_state", ...) the way the reference's start_sweep does, instead of a single generic "Done". The MAX-button click-then-verify and the modal's own X-button close (both calibrated and confirmed live in Phase 9) are reused unchanged -- see plan.md Phase 9/10. """ from ba_auto import detector, navigation OPEN_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 6 STAGE_MODAL_DIM_MAX_CHANNEL = 150 MAX_BUTTON_RETRIES = 3 MODAL_CLOSE_RETRIES = 3 def _is_stage_modal_open(driver, config): r, g, b = driver.color_at(*config.STAGE_MODAL_PROBE) return r < STAGE_MODAL_DIM_MAX_CHANNEL and g < STAGE_MODAL_DIM_MAX_CHANNEL and b < 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): # 掃討開始 always raises a "use N AP to sweep M times?" confirmation # before actually sweeping. Its OK button is this bright cyan. return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN) def _is_ap_purchase_prompt(driver, config): # If AP is too low for even one sweep, a visually similar dialog appears # at the *same* OK-button position but titled "AP購入" (spend real # Pyroxene to buy more AP) with a gold/yellow OK instead of cyan -- # confirmed live by deliberately emptying the sweep count at low AP. # Told apart from the safe confirm above by color, not position. return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD) def _find_result_button(driver, config): # The "掃討完了" (sweep complete) results screen shows a SKIP button # (skips the reward-reveal animation) and then, once settled, a final # OK button -- both the same cyan as the usage-confirm OK, but ~120px # apart vertically. Finding whichever is showing by color avoids # hardcoding both positions. return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN) def _count_raised_above_one(driver, config): # The "-" stepper button is flat grey while count == 1 (its default, # disabled at the minimum) and turns vivid orange once raised -- cheap # way to confirm a MAX/"+" click actually registered without needing OCR # on the count itself. r, g, b = driver.color_at(*config.SWEEP_MINUS_BUTTON_PROBE) return r > 200 and g < 180 and b < 100 def _open_task_screen(driver, config): for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.WORK_ICON) driver.wait(2) if navigation.is_on_subscreen(driver): break print(f"[story_sweep] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})") else: return False for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.TASK_CARD) driver.wait(2) if navigation.is_on_subscreen(driver): return True print(f"[story_sweep] task screen not detected after click (attempt {attempt}/{OPEN_RETRIES})") return False def _read_current_region(driver, config): return detector.read_int(config.REGION_NUMBER_OCR_RECT) def _region_arrow_visible(driver, config, center): # Both arrows render as a solid navy-blue "<"/">" chevron on a # light-blue backdrop when present. The last region in a given direction # (or a locked one, per the reference's own "region-unavailable" # template check) simply omits the arrow rather than greying it out -- # confirmed live: at region 30 (this account's current last region), the # spot where the right arrow would be was plain background. # # Scans a small box around `center` rather than probing a single fixed # point: a chevron is concave, and a centroid-derived single point # landed in the notch between its two strokes -- reading as "absent" # even while the glyph was clearly rendered a few pixels away. See # plan.md Phase 10. cx, cy = center rect = (cx - 40, cy - 35, cx + 40, cy + 35) return detector.region_contains_color(rect, (40, 70, 120), (100, 130, 190)) def _go_to_region(driver, config, target_region): cur = _read_current_region(driver, config) if cur is None: print("[story_sweep] could not OCR the current region number") return False print(f"[story_sweep] current region {cur}, target region {target_region}") for attempt in range(1, config.REGION_NAV_MAX_ATTEMPTS + 1): if cur == target_region: return True going_left = cur > target_region arrow_pos = config.REGION_LEFT_ARROW if going_left else config.REGION_RIGHT_ARROW if not _region_arrow_visible(driver, config, arrow_pos): direction = "left" if going_left else "right" print(f"[story_sweep] region {target_region} unreachable -- no {direction} arrow at region {cur}") return False clicks = abs(cur - target_region) for _ in range(clicks): driver.click(*arrow_pos) driver.wait(1) new_cur = _read_current_region(driver, config) if new_cur is None or new_cur == cur: print(f"[story_sweep] region number unchanged after {clicks} click(s) (attempt {attempt}/{config.REGION_NAV_MAX_ATTEMPTS})") return False cur = new_cur print(f"[story_sweep] gave up navigating to region {target_region} after {config.REGION_NAV_MAX_ATTEMPTS} attempts") return False def _normalize_label(text): # OCR sometimes reads the row's dash as a different dash-like glyph, or # picks up stray whitespace -- normalize before comparing. return text.strip().upper().replace("—", "-").replace("–", "-").replace(" ", "") def _label_suffix(label): # Compare only the part after the dash (e.g. "2" in "30-2", "A" in # "30-A"), not the full "{region}-{stage}" string. Confirmed live: this # font's leading region-number digit is read unreliably by OCR (e.g. "3" # misread as "2") even after threshold preprocessing, while the stage # suffix after the dash reads correctly across every row tested -- and we # don't need the region digit anyway, since _go_to_region has already # independently confirmed we're in the right region. See plan.md Phase 10. parts = [p for p in _normalize_label(label).split("-") if p] return parts[-1] if parts else "" def _row_label_rect(config, row_y): x1, x2 = config.STAGE_LABEL_OCR_X top_pad, bottom_pad = config.STAGE_LABEL_OCR_Y_PAD return (x1, row_y - top_pad, x2, row_y - bottom_pad) def _find_stage_row(driver, config, region, stage): # Scoped-down swipe_search_target_str (see module docstring): this # client's stage list only ever needs the two already-calibrated scroll # extremes checked, not arbitrary swipe-and-retry. target_suffix = str(stage) x, y = config.STAGE_LIST_SCROLL_POINT driver.scroll(x, y, "up", config.STAGE_LIST_SCROLL_CLICKS) driver.wait(0.5) for row_y in config.STAGE_ROWS_AT_TOP_Y: label = detector.read_text(_row_label_rect(config, row_y), whitelist="0123456789-A") print(f"[story_sweep] row @ {row_y} (scrolled up): read '{label}'") if _label_suffix(label) == target_suffix: return row_y driver.scroll(x, y, "down", config.STAGE_LIST_SCROLL_CLICKS) driver.wait(0.5) for row_y in config.STAGE_ROWS_AT_BOTTOM_Y: label = detector.read_text(_row_label_rect(config, row_y), whitelist="0123456789-A") print(f"[story_sweep] row @ {row_y} (scrolled down): read '{label}'") if _label_suffix(label) == target_suffix: return row_y return None def _click_max_and_verify(driver, config): for attempt in range(1, MAX_BUTTON_RETRIES + 1): driver.click(*config.SWEEP_MAX_BUTTON) driver.wait(0.8) if _count_raised_above_one(driver, config): return True print(f"[story_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.SWEEP_PLUS_BUTTON) driver.wait(0.8) if _count_raised_above_one(driver, config): return True print(f"[story_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): # Escape does not close this modal (confirmed live: it stayed open with # focus on the live "任務開始" button after two Escape presses) -- the # only reliable close path is clicking its own X button, verified. for _ in range(MODAL_CLOSE_RETRIES): if not _is_stage_modal_open(driver, config): return True driver.click(*config.STAGE_MODAL_CLOSE_BUTTON) driver.wait(1) return not _is_stage_modal_open(driver, config) def _watch_sweep_result(driver, config): # The reference's start_sweep returns one of "inadequate_ap", # "charge_challenge_counts", or "sweep_complete" so its caller reacts # appropriately -- this ports that same named-outcome contract via # navigation.wait_for_state instead of the old single generic "Done". # # Called only after the usage-confirm dialog is already accepted (see # _sweep_target), so from here it's purely "click through the # 掃討完了 SKIP/OK screens until the bare stage-info modal reappears." # Clicking the found button by color (not a keypress) means this never # risks landing on the underlying "任務開始" button the way a blind # Enter-press loop would. 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 _sweep_target(driver, config, region, stage, count): print(f"[story_sweep] --- target {region}-{stage} x {count} ---") if not _go_to_region(driver, config, region): return "region_unavailable" row_y = _find_stage_row(driver, config, region, stage) if row_y is None: print(f"[story_sweep] stage {region}-{stage} not found in the visible stage list") return "stage_not_found" driver.click(config.STAGE_ENTER_X, row_y) driver.wait(2) if not _is_stage_modal_open(driver, config): print("[story_sweep] stage info panel not detected, aborting") if navigation.is_on_subscreen(driver): driver.keypress("Escape") driver.wait(1.5) return "unrecognized_state" if not _set_sweep_count(driver, config, count): print("[story_sweep] could not confirm sweep count was raised, aborting without spending AP") _close_stage_modal(driver, config) if navigation.is_on_subscreen(driver): driver.keypress("Escape") driver.wait(1.5) return "unrecognized_state" driver.click(*config.SWEEP_START_BUTTON) driver.wait(1.5) if _is_ap_purchase_prompt(driver, config): print("[story_sweep] insufficient AP for this sweep -- cancelling without purchasing") driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) driver.wait(1) _close_stage_modal(driver, config) if navigation.is_on_subscreen(driver): driver.keypress("Escape") driver.wait(1.5) return "inadequate_ap" if not _is_sweep_usage_confirm(driver, config): print("[story_sweep] sweep-usage confirmation not detected, aborting without further input") _close_stage_modal(driver, config) if navigation.is_on_subscreen(driver): driver.keypress("Escape") driver.wait(1.5) return "unrecognized_state" driver.click(*config.SWEEP_CONFIRM_BUTTON) driver.wait(1.5) print("[story_sweep] sweep confirmed, waiting for results") outcome = _watch_sweep_result(driver, config) print(f"[story_sweep] result: {outcome}") if not _close_stage_modal(driver, config): print("[story_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly") return outcome if navigation.is_on_subscreen(driver): driver.keypress("Escape") driver.wait(1.5) return outcome def run(driver, config): driver.focus_game() if not config.STORY_SWEEP_TARGETS: print("[story_sweep] no targets configured (config.STORY_SWEEP_TARGETS is empty), nothing to do") return if not _open_task_screen(driver, config): print("[story_sweep] could not confirm task screen is open, aborting without pressing further keys") return for region, stage, count in config.STORY_SWEEP_TARGETS: outcome = _sweep_target(driver, config, region, stage, count) if outcome == "inadequate_ap": print("[story_sweep] insufficient AP -- stopping, not attempting remaining targets") break if outcome != "swept": print(f"[story_sweep] target {region}-{stage} ended in '{outcome}' -- skipping to next target") print("[story_sweep] Done.")