"""Hard story AP sweep. Reference: baas-reference/module/explore_tasks/sweep_task.py's sweep_hard_task, task_utils.py's to_hard_event/to_region. Ports a fixed, user-supplied priority-ordered list of Hard-mode (region, stage) targets (config.HARD_STORY_SWEEP_TARGETS), each swept via MAX (capped at 3x by the game itself), stopping once AP runs out -- mirroring story_sweep.py's own MAX-per-target convention for config.STORY_SWEEP_TARGETS. Reuses story_sweep.py's WORK_ICON/TASK_CARD entry point, region-nav (REGION_LEFT_ARROW/RIGHT_ARROW, REGION_NUMBER_OCR_RECT), and stage-info modal constants (SWEEP_MAX_BUTTON, SWEEP_START_BUTTON, STAGE_MODAL_CLOSE_BUTTON, SWEEP_CONFIRM_*, SWEEP_RESULT_BUTTON_REGION) directly -- confirmed live 2026-07-20 to be the exact same underlying UI component as Normal mode's. Genuinely different about Hard: a Normal/Hard toggle tab (config.HARD_TAB), always-exactly-3 fixed stage rows with no scrolling/OCR-label search needed (config.HARD_STAGE_ROWS_Y, vs story_sweep's scroll+OCR row search), and a campaign-active guard with no reference equivalent at all (baas-reference has no concept of a drop-rate campaign) -- per explicit user direction, this checks for the pink "キャンペーン中" reward-campaign banner (config. TASK_CAMPAIGN_BADGE_RECT, shared with story_sweep.py's own Normal-mode campaign check added 2026-07-21 -- pixel-identical rect/color, confirmed live) before spending any AP, since Hard sweep is only worth running while Hard-task rewards are boosted. Pass force=True (wired to the `story_sweep_hard_force` CLI command) to bypass that guard. Also carries forward a hard lesson from bounty.py's own real incident (see CLAUDE.md/plan.md): the stage-info modal has TWO stacked action buttons -- the intended cyan 掃討開始 (start sweep, instant) directly above a gold 任務開始 (start mission, a REAL manual battle), both showing an identical AP-cost preview. _confirm_dialog_is_sweep OCR-verifies the confirm dialog's own text before the one irreversible click in this flow, exactly like bounty.py's fix, applied here proactively instead of waiting for a live near-miss to prove it's needed. A second, genuinely new real-money hazard was found live 2026-07-20 (not something bounty.py had to deal with): a Hard stage that already used all 3 of today's auto-sweep clears still shows a clickable 入場 button on the region-browser row, and clicking through to 掃討開始 there raises a completely different gold-button "アラート" dialog offering to refill today's clear count for 40 real Pyroxene/blue gems. See config. HARD_COUNT_FIELD_RECT for the full incident and the two-layer guard (_remaining_sweeps_today's pre-check, _is_challenge_count_alert's OCR fallback) that closes it. """ from ba_auto import detector, navigation OPEN_RETRIES = 3 HARD_TAB_RETRIES = 3 # 6 (story_sweep.py's own value) proved too tight live 2026-07-20: a real 3x # MAX Hard sweep (up to 3x the reward items of a 1x) hit this budget without # ever finding the final result button, ending in "unrecognized_state" # instead of "swept" -- the sweep itself still happened for real (AP/rewards # already committed at the SWEEP_CONFIRM_BUTTON click, before this ever # runs), just misreported. Widened rather than left at parity with # story_sweep.py, whose own STORY_SWEEP_TARGETS default is empty (only ever # sweeps 1x per run in practice), so it never exercised a comparably large # reward-reveal sequence. POST_SWEEP_DISMISS_ROUNDS = 10 STAGE_MODAL_DIM_MAX_CHANNEL = 150 MODAL_CLOSE_RETRIES = 3 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_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 _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 _remaining_sweeps_today(driver, config): """OCR the stage-info modal's own sweep-count field (white digit on a dark navy background, same polarity as arena's Lv. labels -- see detector.read_int_white_on_dark) before ever clicking MAX/掃討開始. A stage already at 0 remaining today still shows a clickable 入場 button on the region-browser row (HARD_STAGE_ENTER_PROBE_HALF_SIZE's unlocked check does NOT catch this) but its modal opens with this count field already defaulted to "0" instead of the healthy default of "1" -- see config.HARD_COUNT_FIELD_RECT for the real incident this guards against (a real gem-cost "refill challenge count?" dialog reached live). """ return detector.read_int_white_on_dark(config.HARD_COUNT_FIELD_RECT) def _is_challenge_count_alert(driver, config): """OCR-detect the real-money "挑戦回数が不足しています...回復させますか?" alert (see config.HARD_COUNT_FIELD_RECT's comment for the full incident) as a fallback safety net in case _remaining_sweeps_today's pre-check somehow missed it (e.g. the count changed between the check and the click). Substring match on "回数" rather than an exact phrase, matching this project's established dialog-classification convention.""" text = detector.read_text(config.HARD_CHALLENGE_ALERT_TEXT_RECT, psm=6, lang="jpn") return "回数" in text 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 CLAUDE.md for the real incident it guards against: a real battle got triggered instead of a sweep, with every check along that path a generic color/position probe and no check on what was actually showing). Reuses config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT directly -- confirmed live 2026-07-20 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 (cafe.py's 衣装/隣 checks, event_sweep.py's "終了" check). """ text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn") return "掃討" in text def _find_result_button(driver, config): 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_hard] 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_hard] task screen not detected after click (attempt {attempt}/{OPEN_RETRIES})") return False def _open_hard_tab(driver, config): for attempt in range(1, HARD_TAB_RETRIES + 1): driver.click(*config.HARD_TAB) driver.wait(1) if _color_in_range(driver.color_at(*config.HARD_TAB_ACTIVE_PROBE), config.HARD_TAB_ACTIVE_RGB): return True print(f"[story_sweep_hard] Hard tab not confirmed active (attempt {attempt}/{HARD_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_hard_screen(driver, config): """Re-verify we're still on the Hard region browser 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. Confirmed live 2026-07-20 this drift is real, not hypothetical: every _sweep_target exit path used to end with "if is_on_subscreen: press Escape" as a leftover-dialog safety net, copied from story_sweep.py -- but the region browser itself already satisfies is_on_subscreen, so that Escape fired after literally every target (success or abort) and backed all the way out to the Work hub. story_sweep.py has the same latent bug but never hit it in practice: its own config.STORY_SWEEP_TARGETS is empty by default (only the single daily rotation target runs), so its loop never reaches a second iteration where the drift would surface. Fixed here by dropping those per-path Escape presses entirely ( _close_stage_modal already leaves us correctly on the region browser) and centralizing recovery in this one loop-level check instead. The color check itself is a cheap single screenshot when nothing has drifted (the common case); a false positive from some other screen coincidentally matching HARD_TAB_ACTIVE_RGB just costs one skipped target via _go_to_region's own safe "could not OCR" failure, not a misclick, so this doesn't need to be perfectly precise. """ if _color_in_range(driver.color_at(*config.HARD_TAB_ACTIVE_PROBE), config.HARD_TAB_ACTIVE_RGB): return True print("[story_sweep_hard] Hard region browser not confirmed before target -- re-opening") return _open_task_screen(driver, config) and _open_hard_tab(driver, config) REGION_READ_RETRIES = 3 def _read_current_region(driver, config): """OCR the current region number, retrying a None read a couple of times before giving up. Confirmed live 2026-07-20: a single failed read here (against a rect/crop later confirmed visually clean and perfectly legible from the same live session) aborted _go_to_region outright, and this same region transition (30 -> 27) failed identically across two separate real runs -- pointing at a transient render/settle race rather than a fundamentally broken crop. A short retry is cheap insurance against that race; it does not paper over a genuinely broken crop, since a real miscalibration would keep failing across all retries too. """ 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): 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_hard] could not OCR the current region number") return False print(f"[story_sweep_hard] 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_hard] 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_hard] region number unchanged after {clicks} click(s) (attempt {attempt}/{config.REGION_NAV_MAX_ATTEMPTS})") return False cur = new_cur print(f"[story_sweep_hard] gave up navigating to region {target_region} after {config.REGION_NAV_MAX_ATTEMPTS} attempts") return False def _stage_row_unlocked(driver, config, row_y): hx, hy = config.HARD_STAGE_ENTER_PROBE_HALF_SIZE rect = (config.STAGE_ENTER_X - hx, row_y - hy, config.STAGE_ENTER_X + hx, row_y + hy) return detector.region_contains_color(rect, *config.SWEEP_CONFIRM_CYAN) def _click_max(driver, config): """Click the MAX button once. Deliberately does NOT require the sweep count to visibly raise above 1 as proof the click landed (the original design, mirroring story_sweep.py's own _click_max_and_verify) -- confirmed live 2026-07-20 that a low-AP run correctly leaves the count at its default of 1 when MAX can only afford exactly 1x (Hard costs 20 AP/hit, so any remaining balance under 40 caps MAX at 1), which is visually indistinguishable from the stepper's own "click didn't land" signal (SWEEP_MINUS_BUTTON_PROBE staying grey). Requiring a visible raise here caused 11 consecutive real targets to be skipped without even being attempted during a real run, despite AP being sufficient for a valid 1x sweep on every one of them. The downstream confirm-dialog checks (_is_ap_purchase_prompt/ _is_sweep_usage_confirm/_confirm_dialog_is_sweep) remain the real safety net: they correctly catch both "not enough AP for even 1x" (stops the whole run) and "this isn't really a sweep confirmation" (the bounty.py hazard class) regardless of what the stepper visually showed here. """ driver.click(*config.SWEEP_MAX_BUTTON) driver.wait(0.8) def _close_stage_modal(driver, config): 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 (six real MAX sweeps in one run, all genuinely successful -- AP spent, subsequent targets proceeded normally) that Hard'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 event_sweep.py's own _watch_sweep_result had to handle (see that module's comment for the original incident). Widening POST_SWEEP_DISMISS_ROUNDS alone did NOT fix this -- every real sweep that run still ended in "unrecognized_state" even with the larger budget, proving this was never a timing issue. Ports event_sweep.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): print(f"[story_sweep_hard] --- target H{region}-{stage} x MAX ---") if not _go_to_region(driver, config, region): return "region_unavailable" row_y = config.HARD_STAGE_ROWS_Y[stage - 1] if not _stage_row_unlocked(driver, config, row_y): print(f"[story_sweep_hard] stage H{region}-{stage} appears locked/unavailable -- skipping") 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_hard] stage info panel not detected, aborting") return "unrecognized_state" remaining = _remaining_sweeps_today(driver, config) if remaining == 0: print(f"[story_sweep_hard] H{region}-{stage} already at 0 remaining sweeps today -- skipping without spending AP or reaching the gem-refill prompt") _close_stage_modal(driver, config) return "daily_limit_reached" _click_max(driver, config) driver.click(*config.SWEEP_START_BUTTON) driver.wait(1.5) # Real-money hazard gate, checked before the AP-purchase/sweep-usage # checks below -- see config.HARD_COUNT_FIELD_RECT's comment for the # full incident (a real "spend 40 gems to refill today's clears?" # dialog). The pre-check above should already prevent reaching this, but # this is the fallback in case the count changed in between. if _is_challenge_count_alert(driver, config): print("[story_sweep_hard] challenge-count gem-refill alert detected -- declining via Escape, never confirming a real gem spend") driver.keypress("Escape") driver.wait(1) _close_stage_modal(driver, config) return "daily_limit_reached" if _is_ap_purchase_prompt(driver, config): print("[story_sweep_hard] 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_hard] 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_hard] 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_hard] sweep confirmed, waiting for results") outcome = _watch_sweep_result(driver, config) print(f"[story_sweep_hard] result: {outcome}") if not _close_stage_modal(driver, config): print("[story_sweep_hard] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly") return outcome def run(driver, config, force=False): driver.focus_game() targets = list(config.HARD_STORY_SWEEP_TARGETS) if not targets: print("[story_sweep_hard] no targets configured (config.HARD_STORY_SWEEP_TARGETS is empty), nothing to do") return if not _open_task_screen(driver, config): print("[story_sweep_hard] could not confirm task screen is open, aborting without pressing further keys") return if not _open_hard_tab(driver, config): print("[story_sweep_hard] could not confirm Hard tab is selected, aborting without pressing further keys") return if force: print("[story_sweep_hard] force=True -- skipping campaign check") else: if not _campaign_active(driver, config): print("[story_sweep_hard] no active Hard-task reward campaign detected -- skipping sweep (run story_sweep_hard_force to override)") return print("[story_sweep_hard] campaign confirmed active, proceeding") for region, stage in targets: if not _ensure_hard_screen(driver, config): print(f"[story_sweep_hard] could not confirm/recover the Hard region browser before target H{region}-{stage} -- stopping") break outcome = _sweep_target(driver, config, region, stage) if outcome == "inadequate_ap": print("[story_sweep_hard] insufficient AP -- stopping, not attempting remaining targets") break if outcome != "swept": print(f"[story_sweep_hard] target H{region}-{stage} ended in '{outcome}' -- skipping to next target") print("[story_sweep_hard] Done.")