diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index edea978..d0e72fc 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -8,7 +8,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect` | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger | | Stamina/AP | `module/collect_daily_free_power.py`, `module/collect_daily_task_power.py` | `to_tasks`/`implement` (task-power, ported); `to_purchase_pyroxenes_menu` (free-power, not ported) | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Partially migrated: Mission-panel claim done (see `plan.md` Phase 8). Daily Free Power (real-money purchase menu) deliberately not automated | | 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. Neither fix has been live-tested against a real recurrence yet. | +| 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`. None of these three fixes has been live-tested against a real recurrence yet. | | 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 | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | diff --git a/ba_auto/tasks/event_sweep.py b/ba_auto/tasks/event_sweep.py index 90097fc..92f96cc 100644 --- a/ba_auto/tasks/event_sweep.py +++ b/ba_auto/tasks/event_sweep.py @@ -47,6 +47,26 @@ click-then-check with no retry, unlike every other click-then-confirm step in this module. _open_stage_modal now retries it the same way, matching CLAUDE.md's own documented history of the mailbox/cafe icons missing their first click and working on retry. + +A third live run surfaced two more instances of the same underlying +problem class: + +1. _find_stage_row's "wrong page" detection (all 5 rows OCR empty) fired + as a FALSE POSITIVE twice in a row on a run that was actually on the + right page the whole time -- the Quest tab's stage list evidently + hadn't finished rendering yet on the first OCR pass right after + navigating there fresh, and 1 second wasn't always enough settle time. + Burning a full return-home-and-re-navigate cycle (the "wrong page" + recovery path) on a pure rendering race wastes retry budget that should + be reserved for an actually-wrong page. _find_stage_row now does a + cheap in-place rescan (re-read the same rows, no re-scroll/re-navigate) + a couple of times before concluding "no valid rows at all." +2. Once past that, the MAX-count-raise succeeded (on its own internal + retry) but the following 掃討開始 (start sweep) click was, again, a bare + single click with no retry -- the click missed, no confirm dialog ever + appeared, and the whole run aborted right at the last step before + actually spending AP. _click_sweep_start_and_verify now wraps it the + same way as every other click in this file. """ import datetime @@ -60,6 +80,9 @@ MODAL_CLOSE_RETRIES = 3 WRONG_PAGE_RETRIES = 3 WRONG_PAGE_RETRY_WAIT = 3 STAGE_ENTER_RETRIES = 3 +STAGE_ROW_SCAN_ATTEMPTS = 3 +STAGE_ROW_SCAN_RETRY_WAIT = 1.5 +SWEEP_START_RETRIES = 3 def _is_stage_modal_open(driver, config): @@ -116,6 +139,18 @@ def _row_number_rect(config, row_y): return (x1, row_y - top_pad, x2, row_y + bottom_pad) +def _scan_stage_rows_once(driver, config, stage): + saw_any_valid_row = False + for row_y in config.EVENT_STAGE_ROW_Y: + label = detector.read_int(_row_number_rect(config, row_y)) + print(f"[event_sweep] row @ {row_y}: read '{label}'") + if label is not None: + saw_any_valid_row = True + if label == stage: + return row_y, saw_any_valid_row + return None, saw_any_valid_row + + def _find_stage_row(driver, config, stage): # This event's target range (9-12) always sits within the last 5 rows, # confirmed live regardless of starting scroll position -- so unlike @@ -126,19 +161,26 @@ def _find_stage_row(driver, config, stage): # stage-row cards, so all 5 reads coming back empty is a strong signal # we're on the wrong page entirely (see module docstring), distinct from # "right page, this stage just isn't among the visible rows." + # + # A fresh navigation's list can still be settling/rendering when the + # first OCR pass runs -- confirmed live, this produced two consecutive + # false-positive "wrong page" reads on a run that was actually on the + # right page the whole time. Rescan in place (no re-scroll/re-navigate) + # a couple of times before concluding "no valid rows at all" -- cheap + # compared to the caller's much more expensive return-home-and-retry + # recovery, which should be reserved for an actually-wrong page. x, y = config.EVENT_STAGE_LIST_SCROLL_POINT driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS) driver.wait(0.5) - saw_any_valid_row = False - for row_y in config.EVENT_STAGE_ROW_Y: - label = detector.read_int(_row_number_rect(config, row_y)) - print(f"[event_sweep] row @ {row_y}: read '{label}'") - if label is not None: - saw_any_valid_row = True - if label == stage: + for attempt in range(1, STAGE_ROW_SCAN_ATTEMPTS + 1): + row_y, saw_any_valid_row = _scan_stage_rows_once(driver, config, stage) + if row_y is not None or saw_any_valid_row: return row_y, saw_any_valid_row - return None, saw_any_valid_row + print(f"[event_sweep] no stage-row numbers recognized on scan {attempt}/{STAGE_ROW_SCAN_ATTEMPTS} -- screen may still be settling") + if attempt < STAGE_ROW_SCAN_ATTEMPTS: + driver.wait(STAGE_ROW_SCAN_RETRY_WAIT) + return None, False def _click_max_and_verify(driver, config): @@ -219,6 +261,21 @@ def _open_stage_modal(driver, config, row_y): return False +def _click_sweep_start_and_verify(driver, config): + # Same missed-click failure class as _open_stage_modal -- a bare single + # click here was found live to leave neither the AP-usage-confirm nor + # the AP-purchase dialog detected, aborting the run at the very last + # step before it would have actually spent AP. Retry-with-verify like + # every other click in this module. + for attempt in range(1, SWEEP_START_RETRIES + 1): + driver.click(*config.EVENT_SWEEP_START_BUTTON) + driver.wait(1.5) + if _is_sweep_usage_confirm(driver, config) or _is_ap_purchase_prompt(driver, config): + return True + print(f"[event_sweep] sweep confirm/AP-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})") + return False + + def _sweep_target(driver, config, stage, count): print(f"[event_sweep] --- target stage {stage} x {count} ---") @@ -239,8 +296,10 @@ def _sweep_target(driver, config, stage, count): _close_stage_modal(driver, config) return "not_sweepable" - driver.click(*config.EVENT_SWEEP_START_BUTTON) - driver.wait(1.5) + if not _click_sweep_start_and_verify(driver, config): + print("[event_sweep] sweep-usage confirmation not detected, aborting without further input") + _close_stage_modal(driver, config) + return "unrecognized_state" if _is_ap_purchase_prompt(driver, config): print("[event_sweep] insufficient AP for this sweep -- cancelling without purchasing") @@ -249,11 +308,6 @@ def _sweep_target(driver, config, stage, count): _close_stage_modal(driver, config) return "inadequate_ap" - if not _is_sweep_usage_confirm(driver, config): - print("[event_sweep] sweep-usage confirmation not detected, aborting without further input") - _close_stage_modal(driver, config) - return "unrecognized_state" - driver.click(*config.SWEEP_CONFIRM_BUTTON) driver.wait(1.5) print("[event_sweep] sweep confirmed, waiting for results") diff --git a/plan.md b/plan.md index c12399b..e23fdfe 100644 --- a/plan.md +++ b/plan.md @@ -503,6 +503,15 @@ This is the same failure class `CLAUDE.md` already documents for the mailbox/caf **Not yet live-tested**: syntax/compile-checked and deployed only. +#### Phase 14 follow-up #3: false-positive wrong-page detection + one more missing retry + +**Reported by the user after a third real run.** Two more instances of the exact same problem class surfaced in one log: + +1. **`_find_stage_row`'s wrong-page detection false-positived twice in a row** on a run that turned out to be on the right page the whole time. The Quest tab's stage list evidently hadn't finished rendering on the first OCR pass right after a fresh navigation (all 5 rows OCR'd empty), and the existing 1-second settle wait wasn't always enough. Since "wrong page" triggers the *expensive* recovery path (`navigation.return_to_home` + a full re-navigate, ~3+ seconds each), this wasted 2 of the outer loop's 3 attempts on what was actually just a rendering race, before the 3rd attempt (now naturally a bit later, giving the UI more time to settle) read all 5 rows correctly. Fixed by adding a *cheap* in-place rescan inside `_find_stage_row` itself (`STAGE_ROW_SCAN_ATTEMPTS = 3`, re-reading the same rows with a short wait between, no re-scroll/re-navigate) before concluding "no valid rows at all" — reserving the expensive return-home recovery for a genuinely wrong page, not a timing race. +2. **Once past that, `_click_max_and_verify` correctly raised the sweep count to MAX (on its own internal retry) but the very next click — 掃討開始 (start sweep) — was still a bare single click with no retry**, the last unguarded click in the whole file. It missed once, no confirm dialog (neither the AP-usage-confirm nor the AP-purchase variant) was ever detected, and the run aborted right before it would have actually spent AP. Fixed via new `_click_sweep_start_and_verify`, the same click-then-verify-then-retry pattern (`SWEEP_START_RETRIES = 3`) as every other click in this file — checking for *either* dialog variant, since which one appears depends on current AP. + +**Not yet live-tested**: syntax/compile-checked and deployed only. Every click in `_sweep_target`'s critical path now follows the same retry pattern; if a future run still fails, the next place to look is `SWEEP_CONFIRM_BUTTON`'s own click (the actual AP-spend-committing click) or `_watch_sweep_result`'s polling loop, the two remaining steps that haven't yet been individually implicated by a real failure. + ## Prerequisites ### OCR