"""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. `_rotation_target` adds a second, date-derived target on top of that static list, per explicit user direction: rather than grinding one fixed stage in their current last region every run, it cycles through that region's stages one per day (a plain date-ordinal modulo, so the cycle doesn't skip or repeat around a year boundary). See config.py's STORY_SWEEP_ROTATION_* constants. - `_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. **2026-07-21 rewrite**: this module predates story_sweep_hard.py's own live debugging (plan.md Phase 21), which found several real bugs in patterns this module had originally established and story_sweep_hard.py had copied verbatim. Per explicit user direction ("match the hard implementation on best practices"), the same fixes are applied here too, even though most were never actually triggered in production (this module's own config.STORY_SWEEP_TARGETS is empty by default, so historically only one rotation target ever ran per invocation -- never enough iterations to expose a multi-target bug): - An explicit Normal-tab guard (`_open_normal_tab`, config.NORMAL_TAB) and a loop-level self-heal (`_ensure_task_screen`), replacing the removed per-path "if on subscreen, press Escape" cleanup that used to fire after *every* target (success or abort) and would back all the way out of the region browser -- confirmed live as a real bug in story_sweep_hard.py, same copied code here. The Normal-tab guard is new, not just a bugfix: story_sweep_hard.py can now run earlier in the same `q4h` preset sequence and leave the Hard tab selected, which this module never had to account for before both tasks coexisted. - `_click_max`/`_click_plus` no longer require the sweep count to visibly rise above 1 as proof a click landed -- low AP can legitimately cap the count at its floor, which is visually indistinguishable from "the click didn't register." `_click_plus` also now forces a known baseline via `_click_min` before applying "+" raises, porting bounty.py's own `_click_min_and_verify` fix for a stepper that remembers its last-used value across opens (see CLAUDE.md/plan.md Phase 15) -- this module's original `_click_plus_and_verify` never did that, a latent version of the exact overspend risk bounty.py hit for real. - `_confirm_dialog_is_sweep` OCR-verifies the confirm dialog's own text before the one irreversible click, closing the same two-stacked-button hazard (cyan 掃討開始 directly above a gold 任務開始 real-battle button) config.py's own SWEEP_MAX_BUTTON comment already documented for this exact tabbed modal layout but this module never actually guarded against. - `_watch_sweep_result` gained the same `clicked_any`-gated second `ends` condition event_sweep.py/story_sweep_hard.py needed: the post-sweep flow can land back on the bare region browser instead of leaving the modal open, which the original "swept" condition required. - `_read_current_region` retries a None OCR read a couple of times before giving up, matching story_sweep_hard.py's own REGION_READ_RETRIES fix for a transient render/settle race confirmed live on the same region browser. - A campaign-active guard, genuinely new (no reference equivalent -- see story_sweep_hard.py's own docstring for why): refuses to spend AP unless the pink "キャンペーン中" reward-campaign banner is showing for the Normal tab specifically (config.TASK_CAMPAIGN_BADGE_RECT, shared with story_sweep_hard.py's own check -- pixel-identical rect/color, confirmed live 2026-07-21, only the tab name in the banner text differs). Pass force=True (wired to the `story_sweep_force` CLI command) to bypass it. """ import datetime from ba_auto import detector, navigation OPEN_RETRIES = 3 NORMAL_TAB_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 6 STAGE_MODAL_DIM_MAX_CHANNEL = 150 MODAL_CLOSE_RETRIES = 3 REGION_READ_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 _confirm_dialog_is_sweep(driver, config): """OCR-verify the confirm dialog reached after clicking 掃討開始 is genuinely the sweep-usage confirm ("APをN使用して、掃討をN回行いますか?"), not some other cyan-styled confirmation that happens to satisfy _is_sweep_usage_confirm's color-only check -- ports bounty.py's own _confirm_dialog_is_sweep fix (see that module and story_sweep_hard.py for the hazard class this guards against: this exact tabbed modal has a gold 任務開始 "start mission" button directly beneath the cyan 掃討開始 one, both showing an identical AP-cost preview -- see config.py's SWEEP_MAX_BUTTON comment). Reuses config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT directly, confirmed live to crop this modal's confirm text correctly too since it's the same shared "通知" dialog component. Substring match on "掃討" rather than an exact match, matching this project's own established dialog-classification convention. """ text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn") return "掃討" in text 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 _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 _open_normal_tab(driver, config): for attempt in range(1, NORMAL_TAB_RETRIES + 1): driver.click(*config.NORMAL_TAB) driver.wait(1) if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB): return True print(f"[story_sweep] Normal tab not confirmed active (attempt {attempt}/{NORMAL_TAB_RETRIES})") return False def _campaign_active(driver, config): return detector.region_contains_color(config.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_CAMPAIGN_BADGE_RGB) def _ensure_task_screen(driver, config): """Re-verify we're still on the task screen's Normal tab before each target, self-healing from any navigation drift between targets the same way ba_daily.py's own _ensure_home does before each task, and the same way story_sweep_hard.py's own _ensure_hard_screen does between its targets -- see that function's docstring for the real incident this pattern was built to fix (a leftover per-path Escape press that backed out of the region browser after every single target). """ if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB): return True print("[story_sweep] task screen (Normal tab) not confirmed before target -- re-opening") return _open_task_screen(driver, config) and _open_normal_tab(driver, config) def _read_current_region(driver, config): """OCR the current region number, retrying a None read a couple of times before giving up -- matching story_sweep_hard.py's own fix for a transient render/settle race confirmed live on this same region browser (a screenshot that read as None was, moments later, confirmed by eye to be a perfectly clean and legible digit). """ for attempt in range(1, REGION_READ_RETRIES + 1): cur = detector.read_int(config.REGION_NUMBER_OCR_RECT) if cur is not None: return cur if attempt < REGION_READ_RETRIES: driver.wait(1) return None 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_min(driver, config): driver.click(*config.SWEEP_MIN_BUTTON) driver.wait(0.8) def _click_max(driver, config): """Click the MAX button once. Deliberately does NOT require the sweep count to visibly rise above 1 as proof the click landed -- see story_sweep_hard.py's own _click_max for the real incident this fixes (low AP can legitimately cap the count at its floor, indistinguishable from "the click didn't register"; 11 real, affordable targets were wrongly skipped by the old verify-and-abort design in that module before this fix). The downstream confirm-dialog checks (_is_ap_purchase_prompt/ _is_sweep_usage_confirm/_confirm_dialog_is_sweep) are the real safety net regardless of what the stepper visually showed here. """ driver.click(*config.SWEEP_MAX_BUTTON) driver.wait(0.8) def _click_plus(driver, config, count): """Raise the sweep count to an exact configured value via "+". Forces a known baseline of 1 via _click_min first -- the stepper remembers its last-used value across opens (confirmed live for real via bounty.py's own overspend incident, plan.md Phase 15/CLAUDE.md), so clicking "+" count-1 times on top of an unknown starting value could under- or over-shoot the intended count. This module's original _click_plus_and_verify never reset to a baseline first -- a latent version of the exact bug bounty.py hit for real, never triggered here only because config.STORY_SWEEP_TARGETS has always been empty in practice. Same as _click_max, does not require a visible post-click raise as proof of success. """ _click_min(driver, config) for _ in range(count - 1): driver.click(*config.SWEEP_PLUS_BUTTON) driver.wait(0.8) def _set_sweep_count(driver, config, count): if count == "max": _click_max(driver, config) else: _click_plus(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): """Confirmed live 2026-07-20/21 (story_sweep_hard.py, plan.md Phase 21) that this shared modal's post-sweep flow can land all the way back on the bare region browser instead of leaving the 任務情報 modal open -- the same terminal-state gap event_sweep.py hit historically. Ports event_sweep.py's/story_sweep_hard.py's own `clicked_any`-gated second `ends` condition: only treat "no modal, no result button" as "swept" once we've actually clicked through at least one result-screen button, so an immediate read on the very first check (before any SKIP/OK sequence has started) still can't be mistaken for a genuine completion. """ 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 _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") return "unrecognized_state" _set_sweep_count(driver, config, count) 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) 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) return "unrecognized_state" # Hard safety gate before the one irreversible click in this whole flow # -- see module docstring / bounty.py's own real incident. Color already # matched above (_is_sweep_usage_confirm); verify the actual dialog text # too before committing rather than trusting color alone. if not _confirm_dialog_is_sweep(driver, config): print("[story_sweep] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming") driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) driver.wait(1) _close_stage_modal(driver, config) 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 def _rotation_target(config): region = getattr(config, "STORY_SWEEP_ROTATION_REGION", None) if not region: return None stage_count = config.STORY_SWEEP_ROTATION_STAGE_COUNT stage = (datetime.date.today().toordinal() % stage_count) + 1 return (region, stage, config.STORY_SWEEP_ROTATION_COUNT) def run(driver, config, force=False): driver.focus_game() ap = navigation.current_ap(driver) if ap is not None and ap < config.SWEEP_MIN_AP: print(f"[story_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do") return targets = list(config.STORY_SWEEP_TARGETS) rotation = _rotation_target(config) if rotation: print(f"[story_sweep] today's rotation target: region {rotation[0]} stage {rotation[1]}") targets.append(rotation) if not targets: print("[story_sweep] no targets configured (config.STORY_SWEEP_TARGETS is empty and rotation is disabled), 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 if not _open_normal_tab(driver, config): print("[story_sweep] could not confirm Normal tab is selected, aborting without pressing further keys") return if force: print("[story_sweep] force=True -- skipping campaign check") else: if not _campaign_active(driver, config): print("[story_sweep] no active Normal-task reward campaign detected -- skipping sweep (run story_sweep_force to override)") return print("[story_sweep] campaign confirmed active, proceeding") for region, stage, count in targets: if not _ensure_task_screen(driver, config): print(f"[story_sweep] could not confirm/recover the task screen before target {region}-{stage} -- stopping") break 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.")