diff --git a/ba_auto/config.py b/ba_auto/config.py index 5fb3a2a..13832c4 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -742,3 +742,128 @@ ARENA_SKIP_ON_RGB = ((60, 205, 235), (140, 255, 255)) # season-record or any other screen this client might show between # confirming attack formation and the fight resolving, if one ever # appears (none did across this one live fight) + +# Bounty (指名手配, module/rewarded_task.py). 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. See ba_auto/tasks/bounty.py's module +# docstring for the full design writeup (per explicit user direction: choose +# one of 3 areas via the same date-ordinal-modulo rotation story_sweep.py/ +# event_sweep.py already use, always sweep that area's latest/highest stage). + +# Work-hub card entry point (WORK_ICON, defined above -> this card). 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. +BOUNTY_CARD = (1113, 645) + +# Location Select's 3 fixed area rows -- all 3 fit on screen with no +# scrolling needed. Index 0/1/2 = ハイウェイ/砂漠の線路/校舎, this client's +# rendering of the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM (matched by +# each area's own recommended-school grouping, e.g. ハイウェイ's +# ゲヘナ/山海経/ヴァルキューレ/ハイランダー lining up with the reference's +# bounty_name[0] = "OVERPASS" grouping). +BOUNTY_AREA_ROW_Y = (300, 475, 650) +BOUNTY_AREA_ROW_X = 1400 +BOUNTY_AREA_NAMES = ("ハイウェイ", "砂漠の線路", "校舎") + +# Stage list, forced to its bottom scroll extreme every run (not just +# trusted to already be there -- see module docstring). 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 -- no +# OCR search needed to locate a specific target the way story_sweep/ +# event_sweep need (their target lists have more entries than fit on screen +# at once; this one doesn't). +BOUNTY_STAGE_LIST_SCROLL_POINT = (1400, 700) +BOUNTY_STAGE_LIST_SCROLL_CLICKS = 25 +BOUNTY_LATEST_STAGE_ROW_Y = 1086 +BOUNTY_STAGE_ENTER_X = 1685 + +# Stage-number OCR crop for the bottom-most row (e.g. "10"), diagnostic/log +# only -- selection is always "bottom-most row," not a text match, so this +# isn't on the decision path. Offset from BOUNTY_LATEST_STAGE_ROW_Y. +BOUNTY_STAGE_NUMBER_OCR_X = (1040, 1120) +BOUNTY_STAGE_NUMBER_OCR_Y_PAD = (35, 35) + +# 任務情報 (task info) modal. MAX button / minus-stepper-probe / start-sweep +# button positions and colors confirmed live pixel-identical to +# EVENT_SWEEP_MAX_BUTTON/EVENT_SWEEP_MINUS_BUTTON_PROBE/ +# EVENT_SWEEP_START_BUTTON (including the exact same raised-vs-default +# minus-button colors, (171,172,171) vs (251,173,152)) -- kept as separate +# BOUNTY_-prefixed constants anyway to avoid coupling the two tasks' +# config together, matching this file's own established pattern (see +# EVENT_STAGE_MODAL_PROBE's comment for the earlier instance of the same +# reasoning). +BOUNTY_SWEEP_MIN_BUTTON = (1178, 511) +BOUNTY_SWEEP_MAX_BUTTON = (1631, 511) +BOUNTY_SWEEP_PLUS_BUTTON = (1517, 511) +BOUNTY_SWEEP_MINUS_BUTTON_PROBE = (1281, 511) +BOUNTY_SWEEP_START_BUTTON = (1400, 668) + +# Modal-open probe. NOT reused from EVENT_STAGE_MODAL_PROBE: that corner +# point (1870, 600) sits on this screen's own dark-blue background +# REGARDLESS of whether the modal is open (confirmed live: both states read +# dark there), since this screen's underlying art differs from event_sweep's +# -- it can't tell the two states apart here. (1850, 1150) does: confirmed +# live dark (<150 all channels) with the modal open, bright +# (160,203,225-ish) with it closed, both directions checked. +BOUNTY_STAGE_MODAL_PROBE = (1850, 1150) +BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL = 150 +# Modal close. Confirmed live this modal DOES close on Escape (like +# event_sweep's, unlike story_sweep's) -- the X button position is kept as a +# fallback, matching event_sweep.py's own close pattern. +BOUNTY_STAGE_MODAL_CLOSE_BUTTON = (1691, 271) + +# The ticket-usage confirm dialog ("指名手配チケットをN使用して、掃討をN回 +# 行いますか?") is the exact same shared "通知" dialog component as the +# AP-usage-confirm dialog -- confirmed live pixel-identical position and +# color to SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/ +# SWEEP_CONFIRM_CYAN (defined above under story_sweep), reused directly. +# SWEEP_CONFIRM_GOLD's use as this same dialog's "can't proceed" variant is +# now live-confirmed too, just via a different trigger than expected: not +# insufficient tickets at the 掃討開始 click (never observed -- this account +# always had enough tickets for the count requested), but a real Pyroxene +# "指名手配チケット購入" (buy more tickets) prompt that appeared automatically +# after a sweep dropped the ticket count to exactly 0 (2026-07-11 live test). +# Same button position/color, confirmed live -- no Pyroxene was actually +# spent (gem balance unchanged before/after), and _watch_sweep_result now +# has its own explicit guard against this state -- see its comment. + +# Post-sweep result screen ("掃討完了"). Live-confirmed 2026-07-11 (count=1, +# the only value possible with the 1 ticket available for this test): a +# SINGLE "OK" button dialog (no separate SKIP step -- unlike story_sweep/ +# event_sweep's SKIP-then-OK sequence, though that may be specific to a +# single-round sweep; a bulk/MAX sweep's result flow is NOT yet confirmed to +# match). Pixel-scanned real OK-button bbox: x 783-1139, y 879-991. +# +# The FIRST live test of this (also 2026-07-11) used a naive copy of +# EVENT_SWEEP_RESULT_BUTTON_REGION (1000-1300 x, 700-1050 y) and hit a real, +# confirmed-live false positive: that region's y-range overlapped +# BOUNTY_SWEEP_START_BUTTON's own real cyan pixels (掃討開始 spans y +# 622-715), so once the real result dialog was dismissed and the plain +# stage-info modal reappeared (with 0 tickets left), _find_result_button +# re-matched 掃討開始's corner and clicked it -- which is what triggered the +# real ticket-purchase prompt described above. Narrowed here (matching +# EVENT_SWEEP_RESULT_BUTTON_REGION's own history of being fixed the same +# way after its own live false-positive) to a tight box around the +# confirmed real OK-button bbox, comfortably clear of 掃討開始's y-range. +BOUNTY_SWEEP_RESULT_BUTTON_REGION = (750, 850, 1160, 1010) + +# A SECOND, smaller contamination source was also found live within the +# corrected region above: the modal's own "獲得期待報酬" reward-icon artwork +# (visible whenever the plain stage-info modal is showing, sitting at the +# same y-position as the result dialog's OK button) has a handful of pixels +# that incidentally fall inside SWEEP_CONFIRM_CYAN's broad range too -- +# confirmed live at ~878 stray pixels vs the real OK button's ~34,000. +# find_color_centroid's min_pixels parameter (see detector.py) filters this +# out; set well below the real button's count and well above the confirmed +# noise floor, with wide margin on both sides for icon-art variance across +# different stages/areas this project hasn't captured yet. +BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000 + +# Which area to sweep today, per explicit user request (2026-07-11): "3 +# areas, choose random (mod%3), run the latest stage available." Same +# date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*. +BOUNTY_SWEEP_COUNT = "max" diff --git a/ba_auto/detector.py b/ba_auto/detector.py index 484b113..e38db88 100644 --- a/ba_auto/detector.py +++ b/ba_auto/detector.py @@ -121,19 +121,26 @@ def region_contains_color(region, rgb_min, rgb_max): return bool(_color_mask(region, rgb_min, rgb_max).any()) -def find_color_centroid(region, rgb_min, rgb_max): +def find_color_centroid(region, rgb_min, rgb_max, min_pixels=1): """Centroid `(x, y)` of every pixel within `region` (x1, y1, x2, y2) - falling in the given RGB range, or None if none match. Useful for - clicking a known-colored button whose exact position varies between - otherwise-similar dialogs -- e.g. story_sweep's sweep-result screen - shows the same cyan confirm-button color for its "SKIP" and final "OK" - states, ~120px apart vertically; finding it by color avoids hardcoding - both positions. See plan.md Phase 10. + falling in the given RGB range, or None if fewer than `min_pixels` + match. Useful for clicking a known-colored button whose exact position + varies between otherwise-similar dialogs -- e.g. story_sweep's + sweep-result screen shows the same cyan confirm-button color for its + "SKIP" and final "OK" states, ~120px apart vertically; finding it by + color avoids hardcoding both positions. See plan.md Phase 10. + + `min_pixels` defaults to 1 (any match counts, the original behavior) -- + raise it when the region can also contain sparse, incidental matches of + the same color from unrelated content (e.g. bounty.py's result-button + region briefly overlapping a reward-icon's own artwork, confirmed live + to produce a few hundred stray matching pixels vs a real button's tens + of thousands -- see config.BOUNTY_RESULT_BUTTON_MIN_PIXELS). """ x1, y1, x2, y2 = region mask = _color_mask(region, rgb_min, rgb_max) ys, xs = np.nonzero(mask) - if len(xs) == 0: + if len(xs) < min_pixels: return None return (x1 + int(xs.mean()), y1 + int(ys.mean())) diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index f8d969b..59dba3b 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -10,7 +10,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Group/Club AP | `module/group.py` | Need to inspect | `ba_auto/tasks/group.py` | fixed click + state check via local driver | Not started | -| Bounty | `module/rewarded_task.py` | Need to inspect | `ba_auto/tasks/bounty.py` | sweep/color/OCR adaptation | Not started | +| Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | diff --git a/ba_auto/tasks/bounty.py b/ba_auto/tasks/bounty.py new file mode 100644 index 0000000..c045cf7 --- /dev/null +++ b/ba_auto/tasks/bounty.py @@ -0,0 +1,395 @@ +"""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.") diff --git a/ba_daily.py b/ba_daily.py index 3f09f19..abf7054 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -3,7 +3,7 @@ import sys from ba_auto import config, driver -from ba_auto.tasks import arena, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep +from ba_auto.tasks import arena, bounty, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep TASKS = { "mailbox": mailbox.run, @@ -15,13 +15,15 @@ TASKS = { "shop_tactical": shop_tactical.run, "lesson": lesson.run, "arena": arena.run, + "bounty": bounty.run, } -# story_sweep, event_sweep, both shop tasks, lesson, and arena are opt-in -# only (not in the default flow): they spend AP/credits/tactical coin/lesson -# tickets/an arena ticket on an automated choice rather than reclaiming -# something free, which is a real resource decision the default unattended -# run shouldn't make blindly. Arena specifically fights a real ranked PvP -# battle each run -- see ba_auto/tasks/arena.py's module docstring. +# story_sweep, event_sweep, both shop tasks, lesson, arena, and bounty are +# opt-in only (not in the default flow): they spend AP/credits/tactical +# coin/lesson tickets/an arena ticket/a bounty ticket on an automated choice +# rather than reclaiming something free, which is a real resource decision +# the default unattended run shouldn't make blindly. Arena specifically +# fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's +# module docstring. DEFAULT_ORDER = ["mailbox", "cafe", "stamina"] diff --git a/plan.md b/plan.md index f20f27e..3e1e80f 100644 --- a/plan.md +++ b/plan.md @@ -562,6 +562,20 @@ This is the same failure class `CLAUDE.md` already documents for the mailbox/caf **Status**: both bugs fixed. The originally reported bug (stage 9 unreadable) is fully confirmed via a real successful live sweep. The `_watch_sweep_result` mislabeling's real root cause is now understood and fixed (previous fix attempt addressed a real but secondary issue); confirmed against the actual live false-positive condition, awaiting one more full live sweep to confirm the outcome log itself reads `"swept"` next time AP allows. +### Phase 15: Bounty + +Reference: `module/rewarded_task.py`. Local: `ba_auto/tasks/bounty.py`. + +Per explicit user direction (2026-07-11): "There are 3 areas in the Bounty (指名手配), choose random (mod%3), and run the latest stage available (currently J)." Simplified from the reference's own per-call loop across all 3 areas (config-string sweep counts via `get_task_count`/`rewarded_task_times`, which this project has no equivalent config for) plus its `get_los`/`one_detect` per-row SSS-color scan, the same way event_sweep.py already simplified `sweep_activity.py` — one area per run via date-ordinal-modulo rotation, always the area's bottom-most (= latest) stage. + +**Live-calibrated 2026-07-11, zero real tickets spent during calibration** (every confirm dialog cancelled via Escape, ticket count 6/6 confirmed unchanged before/after, across all 3 areas): entry is a Work-hub `指名手配` card (not a bottom-nav "bus" icon like the reference), landing directly on Location Select; all 3 areas (ハイウェイ/砂漠の線路/校舎 = OVERPASS/DESSERT RAILWAY/CLASSROOM) confirmed identical in layout — exactly 10 stages each (01-10, lettered A-J), all already SSS-cleared, row 10 the true list end (scrolling further is a no-op). This makes "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction, unlike story_sweep/event_sweep which need an OCR search over a longer-than-visible list. The 任務情報 modal's MAX stepper and the ticket-usage confirm dialog are pixel-identical to event_sweep's own stage modal/shared `SWEEP_CONFIRM_*`, reused directly; the modal DOES close on Escape (like event_sweep's). The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`, though — that corner point reads dark on this screen regardless of modal state (different background art); a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that discriminates correctly. + +**Live-tested for real, with a real bug found and fixed along the way.** The user approved a controlled 1-ticket live test to confirm the result-screen handling (deliberately not exercised during zero-spend calibration). The test spent **5 tickets instead of 1** (6/6 → 1/6): `_set_sweep_count`'s `count == 1` path assumed the modal always defaults to count=1 on open, and short-circuited without clicking anything — but the modal actually remembers the last-used count (carried over from an earlier calibration session's MAX click on a *different* stage/area), so the "no click needed" assumption silently confirmed whatever count was actually showing. A real sweep executed regardless (credits +180,000, confirmed screenshot, safe automatic return home), so nothing was lost, but the spend didn't match what was communicated to the user. **Fixed**: `_set_sweep_count` now always forces a known baseline via a new `_click_min_and_verify` (clicks `BOUNTY_SWEEP_MIN_BUTTON`, symmetric to the existing MAX click) before applying any `+` raises, for any count including 1 — never trusts an assumed default again. + +With that fixed, the user approved spending the account's one remaining ticket on a fully-diagnostic run (polling and screenshotting every step of the result window instead of trusting the production code blindly). This surfaced a second, more serious near-miss: after the ticket count hit exactly 0, `_find_result_button`'s color search (still using a first-guess region copied from `EVENT_SWEEP_RESULT_BUTTON_REGION`) re-matched part of `BOUNTY_SWEEP_START_BUTTON`'s own real cyan pixels once the result dialog closed and the plain stage-info modal reappeared — clicking it re-triggered a **real Pyroxene (gem) ticket-purchase prompt**, the same shared gold-button dialog component as the AP tasks' insufficient-AP variant. The diagnostic loop's blind repeated clicks near this dialog never actually landed on its real "OK" button (confirmed: gem balance 10,494 unchanged before/after), but this was closer to an unintended real-currency spend than any previous task in this project has come. **Fixed three ways**: (1) `BOUNTY_SWEEP_RESULT_BUTTON_REGION` corrected from the first-guess copy to the real, pixel-scanned OK-button bbox (confirmed via offline analysis of the saved diagnostic screenshots — x 783–1139, y 879–991 — with zero further game interaction needed), now clear of `BOUNTY_SWEEP_START_BUTTON`'s own y-range (622–715); (2) a second, smaller contamination source found in the same offline analysis — the modal's own reward-icon artwork has a handful of pixels that incidentally fall inside the cyan color range even with no dialog showing (~878 stray pixels vs. the real button's ~34,000) — fixed via a new `min_pixels` parameter on `detector.find_color_centroid` (default 1, preserving existing callers' behavior unchanged) and `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000`, comfortably separating real matches from noise; (3) `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` check as one of its `ends` conditions, so if this state is ever reached again for any reason, it's recognized and cancelled outright rather than left to a blind color search near a real-currency dialog. All three fixes were verified offline against the actual saved screenshots from the diagnostic run (the plain-modal and purchase-prompt states now correctly return no match; the real result dialog still matches correctly) before redeploying — no further ticket was available to re-test live same-day. + +**Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here). + ## Prerequisites ### OCR