"""Bounty (指名手配). Reference: baas-reference/module/rewarded_task.py. Ported per explicit user direction (2026-07-11): 3 areas (ハイウェイ/砂漠の 線路/校舎 -- this client's rendering of the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM), choose one via the same date-ordinal-modulo rotation story_sweep.py/event_sweep.py already use, and always sweep that area's latest/highest-numbered stage. Rather than the reference's own per-call loop across all 3 areas by a configured sweep-count-per-area (get_task_count/rewarded_task_times -- config this project has no equivalent for) plus its get_los/one_detect star-color SSS-availability scan, this sweeps exactly one area's one (always-latest) stage per run, mirroring event_sweep.py's own simplification of its reference (sweep_activity.py) for the same reason. Live-calibrated against nik-gpu 2026-07-11, zero real tickets spent (every confirm dialog reached was cancelled via Escape, ticket count 6/6 confirmed unchanged before/after, across all 3 areas): - Entry: WORK_ICON -> config.BOUNTY_CARD click lands directly on the Location Select screen -- no separate bus-icon/sub-navigation step needed on this client, unlike the reference's bottom-nav "main_page_bus" icon. 指名手配 is a Work-hub card here, matching story_sweep's Task card / arena's Tactical Challenge card. - Location Select's 3 area rows are a fixed list, not scrollable/paged -- config.BOUNTY_AREA_ROW_Y indexes directly by rotation. - Each area's stage list is forced to its bottom scroll extreme every run (not just trusted to already be there, per event_sweep.py's own reasoning for the same kind of state). Confirmed live across all 3 areas: exactly 10 stages per area (numbered 01-10, lettered A-J), row 10 is the true list end (scrolling further is a no-op), and every stage was already 3-starred/SSS-cleared. This makes the bottom-most row always literally "the latest stage available" by construction -- unlike story_sweep/ event_sweep, no OCR search is needed to locate a specific target within a longer-than-visible list. - No never-cleared stage was available to confirm the reference's SSS-availability gate against (every stage on this account already was). This reuses story_sweep/event_sweep's same defensive fallback instead: if the MAX-button count-raise can't be verified, abort without spending a ticket rather than guess. - The 任務情報 (task info) modal's MAX/count stepper and the ticket-usage confirm dialog ("指名手配チケットをN使用して...") are pixel-identical in position/color to event_sweep's own stage modal/SWEEP_CONFIRM_* dialog -- confirmed live -- and this reuses SWEEP_CONFIRM_* directly rather than re-declaring duplicates (same pattern story_sweep/event_sweep already share that dialog with). Confirmed live this modal DOES close on Escape, like event_sweep's (not story_sweep's, which needs its own X button). - The modal-open probe could NOT be reused from event_sweep's EVENT_STAGE_MODAL_PROBE -- that corner point reads dark on this screen regardless of modal state, since the underlying background art differs. config.BOUNTY_STAGE_MODAL_PROBE is a freshly calibrated point that does discriminate both ways. Live-tested for real (2026-07-11, 2 real sweeps, credits gained both times, clean automatic return home both times), which found and fixed two real bugs -- see plan.md's Bounty phase (Phase 15) for the full writeup: 1. `_set_sweep_count`'s original count==1 path wrongly assumed the modal always defaults to count=1 on open and skipped clicking anything -- the modal actually remembers the last-used count across stages/areas, so this silently confirmed a real 5-ticket sweep instead of the intended 1. Fixed via `_click_min_and_verify`, which always forces a known baseline (the MIN button, symmetric to the existing MAX click) before applying any "+" raises, for any count including 1. 2. config.BOUNTY_SWEEP_RESULT_BUTTON_REGION's first-guess copy of event_sweep's own region overlapped BOUNTY_SWEEP_START_BUTTON's real cyan pixels. Once a sweep dropped the ticket count to exactly 0, _find_result_button re-matched that button's corner after the real result dialog had already closed, and clicking it surfaced a REAL Pyroxene ticket-purchase prompt (the same shared gold-button dialog component as the AP tasks' insufficient-AP variant). No Pyroxene was actually spent (gem balance confirmed unchanged), but this was a real near-miss, not a hypothetical one. Fixed three ways: the region was corrected to the real, pixel-scanned OK-button bbox; a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork, confirmed live) is filtered via a new min_pixels parameter on detector.find_color_centroid (config.BOUNTY_RESULT_BUTTON_MIN_PIXELS); and _watch_sweep_result now has an explicit _is_ticket_purchase_prompt check as one of its own `ends` conditions, so this state is recognized and safely cancelled outright if it's ever reached again, rather than left to a blind color search near a real-currency dialog. NOT yet re-confirmed live: a fresh sweep with these fixes deployed (the account was left at 0/6 tickets by this session's own testing) and any bulk/MAX-count sweep's result-screen flow specifically -- only count=1 was ever tested (ticket scarcity forced it), so it's not yet confirmed whether a bulk sweep shows the same single-OK dialog or a SKIP-then-OK sequence like story_sweep/event_sweep's own bulk-sweep result screens. Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17), reported live by the user: a real "Battle Complete" screen (a live combat timer, ~3 minutes) was found on the account instead of an instant sweep having happened, and every task that ran afterward in that same `daily` invocation failed to open its own screen for the rest of the run -- this task's own `_watch_sweep_result` had reported "swept" cleanly, with no warnings anywhere in the log. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, same safe-calibration pattern as the original Phase 15 work) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical "N→N-1" ticket-cost preview. Every check along this task's own commit path (`_count_raised_above_one`, `_is_sweep_usage_confirm`, `_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of WHAT is actually showing -- the exact same class of false-positive risk that repeatedly fooled login.py's single/few-point checks the same day (see plan.md's Phase 18 follow-up #3), just with much higher stakes here (an actual battle, not a misread loading screen). The precise mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (doing so would mean deliberately repeating a real battle) -- but the fix closes the hazard regardless of the exact upstream cause: `_confirm_dialog_ is_sweep` OCR-verifies the confirm dialog's actual text ("指名手配チケットをN使用して、掃討をN回行いますか?", captured live from the real dialog) before the one irreversible click in this whole flow, and cancels rather than confirms if it doesn't read as a sweep. Not yet re-verified live against a fresh real sweep (would need to spend another real ticket) -- the dialog-text capture and cancel-without-confirming were both done live, but the actual gate has not yet been exercised by a genuine full run. """ import datetime from ba_auto import detector, navigation OPEN_RETRIES = 3 AREA_RETRIES = 3 STAGE_ENTER_RETRIES = 3 MAX_BUTTON_RETRIES = 3 MODAL_CLOSE_RETRIES = 3 SWEEP_START_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 14 def _is_stage_modal_open(driver, config): r, g, b = driver.color_at(*config.BOUNTY_STAGE_MODAL_PROBE) return r < config.BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.BOUNTY_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_ticket_purchase_prompt(driver, config): 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 ("指名手配チケットをN使用して、掃討を N回行いますか?"), not some other cyan-styled confirmation that happens to satisfy _is_sweep_usage_confirm's color-only check -- see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real incident this guards against (a real battle got triggered instead of a sweep, and every check along that path was a generic color/position probe with no check on what was actually showing). 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.BOUNTY_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN, min_pixels=config.BOUNTY_RESULT_BUTTON_MIN_PIXELS, ) def _count_raised_above_one(driver, config): # Same coral/orange color-spread check as event_sweep's own minus-button # probe -- confirmed live to read the exact same colors here, # (171,172,171) default vs (251,173,152) raised. r, g, b = driver.color_at(*config.BOUNTY_SWEEP_MINUS_BUTTON_PROBE) return (max(r, g, b) - min(r, g, b)) > 40 def _open_bounty_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"[bounty] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})") else: return False for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.BOUNTY_CARD) driver.wait(2) if navigation.is_on_subscreen(driver): return True print(f"[bounty] location select not detected after click (attempt {attempt}/{OPEN_RETRIES})") return False def _open_area(driver, config, area_index): row_y = config.BOUNTY_AREA_ROW_Y[area_index] for attempt in range(1, AREA_RETRIES + 1): driver.click(config.BOUNTY_AREA_ROW_X, row_y) driver.wait(2) if navigation.is_on_subscreen(driver): return True print(f"[bounty] stage list not detected after clicking area {area_index} (attempt {attempt}/{AREA_RETRIES})") return False def _row_number_rect(config): x1, x2 = config.BOUNTY_STAGE_NUMBER_OCR_X top_pad, bottom_pad = config.BOUNTY_STAGE_NUMBER_OCR_Y_PAD row_y = config.BOUNTY_LATEST_STAGE_ROW_Y return (x1, row_y - top_pad, x2, row_y + bottom_pad) def _open_latest_stage_modal(driver, config): x, y = config.BOUNTY_STAGE_LIST_SCROLL_POINT driver.scroll(x, y, "down", config.BOUNTY_STAGE_LIST_SCROLL_CLICKS) driver.wait(0.5) label = detector.read_int(_row_number_rect(config)) print(f"[bounty] latest stage row reads '{label}' (diagnostic only -- selection is always the bottom-most row)") row_y = config.BOUNTY_LATEST_STAGE_ROW_Y for attempt in range(1, STAGE_ENTER_RETRIES + 1): driver.click(config.BOUNTY_STAGE_ENTER_X, row_y) driver.wait(2) if _is_stage_modal_open(driver, config): return True print(f"[bounty] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})") return False def _click_max_and_verify(driver, config): for attempt in range(1, MAX_BUTTON_RETRIES + 1): driver.click(*config.BOUNTY_SWEEP_MAX_BUTTON) driver.wait(0.8) if _count_raised_above_one(driver, config): return True print(f"[bounty] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") return False def _click_min_and_verify(driver, config): # Force a known baseline before applying "+" clicks -- confirmed live # (2026-07-11 live-test bug) that the modal does NOT reliably default to # count=1 on open: it carried over count=6 from an earlier calibration # session on a completely different stage/area, and a since-removed # "count==1 means skip, no click needed" shortcut trusted that false # default and silently confirmed a real 5-ticket sweep instead of the # intended 1. MIN reliably jumps to the minimum the same way MAX jumps # to the maximum (the two are symmetric stepper endpoints) -- unlike # MAX, though, "back at the minimum" can't be verified via # _count_raised_above_one (that check's whole point is detecting a # raise above 1), so this checks the inverse: the minus-button probe # back at its flat, non-raised color. for attempt in range(1, MAX_BUTTON_RETRIES + 1): driver.click(*config.BOUNTY_SWEEP_MIN_BUTTON) driver.wait(0.8) if not _count_raised_above_one(driver, config): return True print(f"[bounty] MIN click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})") return False def _click_plus_and_verify(driver, config, count): if not _click_min_and_verify(driver, config): print("[bounty] could not confirm count was reset to the minimum before raising it -- aborting rather than risk an unverified starting count") return False if count == 1: return True for attempt in range(1, MAX_BUTTON_RETRIES + 1): for _ in range(count - 1): driver.click(*config.BOUNTY_SWEEP_PLUS_BUTTON) driver.wait(0.8) if _count_raised_above_one(driver, config): return True print(f"[bounty] 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 (like event_sweep's) -- # 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.BOUNTY_STAGE_MODAL_CLOSE_BUTTON) driver.wait(1) return not _is_stage_modal_open(driver, config) def _watch_sweep_result(driver, config): # Same clicked_any-gated two-ends-condition pattern event_sweep.py's own # _watch_sweep_result uses, since this modal shares that same underlying # "may auto-return past the bare stage modal all the way to the list" # risk -- confirmed live (2026-07-11, count=1): after the single "OK" # result button is clicked, the flow returns to the bare stage-info # modal (not all the way to the list), so the first `ends` condition is # the one that actually fires in practice, but the second is kept as a # defensive fallback in case a bulk/MAX sweep (not yet live-tested) # behaves differently. # # ALSO confirmed live (same run): once held tickets hit exactly 0, # re-clicking 掃討開始 (which _find_result_button's region used to # overlap -- see BOUNTY_SWEEP_RESULT_BUTTON_REGION's own comment for the # false-positive this caused) surfaces a REAL Pyroxene ticket-purchase # dialog, the same shared "通知" component as SWEEP_CONFIRM_GOLD's # insufficient-AP variant elsewhere in this project. The region fix # + find_color_centroid's new min_pixels threshold should prevent # _find_result_button from ever clicking into that button's footprint # again, but this explicit check is kept as a second, independent layer # -- if this state is ever reached anyway, cancel it outright rather # than let the loop keep hunting for cyan pixels near a real-currency # purchase button. No ticket/Pyroxene was actually spent when this was # found live (confirmed by an unchanged gem balance before/after), but # this closes the gap rather than relying on that being luck. 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_ticket_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 _click_sweep_start_and_verify(driver, config): for attempt in range(1, SWEEP_START_RETRIES + 1): driver.click(*config.BOUNTY_SWEEP_START_BUTTON) driver.wait(1.5) if _is_sweep_usage_confirm(driver, config) or _is_ticket_purchase_prompt(driver, config): return True print(f"[bounty] sweep confirm/ticket-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})") return False def _sweep_latest_stage(driver, config, area_index, count): area_name = config.BOUNTY_AREA_NAMES[area_index] print(f"[bounty] --- area {area_index} ({area_name}), latest stage x {count} ---") if not _open_area(driver, config, area_index): return "area_unavailable" if not _open_latest_stage_modal(driver, config): print("[bounty] stage info panel not detected, aborting") return "unrecognized_state" if not _set_sweep_count(driver, config, count): print("[bounty] could not confirm sweep count was raised (stage may not be SSS-cleared/sweepable yet) -- aborting without spending a ticket") _close_stage_modal(driver, config) return "not_sweepable" if not _click_sweep_start_and_verify(driver, config): print("[bounty] sweep-usage confirmation not detected, aborting without further input") _close_stage_modal(driver, config) return "unrecognized_state" if _is_ticket_purchase_prompt(driver, config): print("[bounty] insufficient bounty tickets for this sweep -- cancelling without purchasing") driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON) driver.wait(1) _close_stage_modal(driver, config) return "inadequate_ticket" # Hard safety gate before the one irreversible click in this whole # flow -- see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real # incident (a real battle got triggered instead of a sweep) this # closes. _is_sweep_usage_confirm already passed (a color match), but # that alone isn't proof this is really the sweep dialog -- verify the # actual text before committing rather than trusting color alone. if not _confirm_dialog_is_sweep(driver, config): print("[bounty] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming (see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT)") 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("[bounty] sweep confirmed, waiting for results") outcome = _watch_sweep_result(driver, config) print(f"[bounty] result: {outcome}") if outcome == "prompted_to_purchase": # See _watch_sweep_result's own comment -- cancel this real-currency # dialog explicitly rather than let _close_stage_modal's plain # Escape loop be the only thing standing between it and a real # Pyroxene spend. print("[bounty] ticket-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("[bounty] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly") return outcome def _rotation_area(config): area_count = len(config.BOUNTY_AREA_NAMES) return datetime.date.today().toordinal() % area_count def run(driver, config): driver.focus_game() # Reused from arena.py's own fix for the same class of bug (see # plan.md's Phase 13 follow-up): WORK_ICON's click is home-relative, so # a prior run left mid-navigation would send it to the wrong place. navigation.return_to_home(driver) area_index = _rotation_area(config) print(f"[bounty] today's rotation target: area {area_index} ({config.BOUNTY_AREA_NAMES[area_index]})") if not _open_bounty_screen(driver, config): print("[bounty] could not confirm bounty location-select screen is open, aborting without pressing further keys") navigation.return_to_home(driver) return outcome = _sweep_latest_stage(driver, config, area_index, config.BOUNTY_SWEEP_COUNT) if outcome != "swept": print(f"[bounty] area {area_index} ended in '{outcome}'") if not navigation.return_to_home(driver): print("[bounty] warning: could not confirm return to home screen") print("[bounty] Done.")