"""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. """ 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 _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" 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.")