From af0a784bcd98a579a9a8acde46d3471bed9480ca Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Fri, 10 Jul 2026 12:33:22 +0900 Subject: [PATCH] fix(event_sweep): implement wrong/stale event page detection and shared navigation return to home functionality --- ba_auto/config.py | 18 ++++++--- ba_auto/navigation.py | 43 +++++++++++++++++++++ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/event_sweep.py | 61 ++++++++++++++++++++++++------ plan.md | 11 ++++++ 5 files changed, 118 insertions(+), 17 deletions(-) diff --git a/ba_auto/config.py b/ba_auto/config.py index 673270c..329c688 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -216,12 +216,20 @@ STORY_SWEEP_ROTATION_COUNT = "max" # (every confirm dialog reached was cancelled via Escape, verified by the AP # counter being unchanged before/after). -# Home screen's top-right event countdown badge/thumbnail (e.g. "終了まで -# あと11日"). Confirmed live as the only reliable entry point: the bottom-left -# banner slot cycles between several unrelated banners (gacha pickups, other -# campaigns) and does not consistently reach the current story event. +# Home screen's top-right event badge/thumbnail slot. Chosen over the +# bottom-left banner slot, which cycles between several unrelated banners +# (gacha pickups, other campaigns) and was confirmed live to not reliably +# reach the current story event either. This slot is ALSO a rotating +# carousel, though -- confirmed live post-launch: it cycles between the +# current event's own countdown (e.g. "終了まであと11日") and OTHER notices, +# including an already-finished event's remaining reward-claim-period +# reminder, so a single click can land on a stale event's page instead of +# the current one. event_sweep.py's own wrong-page detection + retry (via +# navigation.return_to_home) exists specifically to recover from this, since +# no fixed click here is guaranteed to hit the right content on the first +# try. Back-button navigation now goes through navigation.return_to_home's +# shared BACK_BUTTON constant instead of a dedicated config entry here. EVENT_BADGE_ICON = (1787, 300) -EVENT_BACK_BUTTON = (85, 55) # Top-right tab bar inside the event screen (reference's activity_menu # story/mission/challenge tabs -- rendered as English labels "Story / Quest / diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index fda283c..f845d40 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -9,6 +9,14 @@ SUBSCREEN_HEADER_MIN_CHANNEL = 200 MODAL_DIM_PROBE = (960, 200) MODAL_DIM_MAX_CHANNEL = 150 +# Shared top-left back-arrow position -- every subscreen calibrated so far +# (mailbox, cafe, shop, lesson, event) puts its own back button here +# (confirmed identical across config.py's LESSON_BACK_BUTTON/ +# SHOP_BACK_BUTTON/EVENT_BACK_BUTTON). Used by return_to_home below as a +# generic recovery affordance, independent of which task got stuck. +BACK_BUTTON = (85, 55) +RETURN_HOME_MAX_ROUNDS = 6 + def is_on_subscreen(driver): r, g, b = driver.color_at(*SUBSCREEN_HEADER_PROBE) @@ -20,6 +28,41 @@ def is_modal_open(driver): return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL +def return_to_home(driver): + """Bounded "press back until the home screen is reached" loop -- the + shared recovery path for any task that ends up on an unexpected or wrong + subscreen (e.g. event_sweep landing on a stale/finished event's page + instead of the current one, see plan.md's Event sweep phase). Generic + across tasks: it only depends on is_on_subscreen and the shared + BACK_BUTTON position above, not on any task-specific state. + + Checks is_on_subscreen before every single press and stops the instant + it reads False -- never presses Escape/clicks back while already on the + home screen. This matters: CLAUDE.md documents a real hazard where a + blind Escape press on the home screen itself raises Blue Archive's own + "exit the game?" confirmation, which is exactly the failure mode a + naive fixed-count blind-press loop could cause here. + + Tries Escape first each round (works for most subscreens, confirmed for + mailbox/cafe/event's own stage modal) and falls back to clicking + BACK_BUTTON if Escape didn't clear it, re-checking after each. + + Returns True once home is confirmed reached, False if still not home + after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort, + don't guess further" rather than assume home was reached. + """ + for _ in range(RETURN_HOME_MAX_ROUNDS): + if not is_on_subscreen(driver): + return True + driver.keypress("Escape") + driver.wait(1) + if not is_on_subscreen(driver): + return True + driver.click(*BACK_BUTTON) + driver.wait(1) + return not is_on_subscreen(driver) + + def wait_for_state(driver, config, reactions, ends, max_iterations=30, poll_interval=1.0): """Generic "watch the screen, react to anything recognized, stop once a recognized destination is reached" loop -- the local equivalent of the diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index f5adcb6..fd49510 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` | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event countdown badge (`config.EVENT_BADGE_ICON`); 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. 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). NOT yet live-tested with a real sweep/AP spend, and 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. See `plan.md`'s Event sweep phase | +| 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). Not yet live-tested against a real recurrence of the bug. | | 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 5d8ed44..5c842f0 100644 --- a/ba_auto/tasks/event_sweep.py +++ b/ba_auto/tasks/event_sweep.py @@ -26,6 +26,19 @@ Key differences from story_sweep.py, all confirmed live during calibration: - The stage modal DOES close on Escape, unlike story_sweep's (X-button-only). - Both stages tested were already 3-starred; the reference's SSS-availability gate for never-cleared stages was never actually exercised (see config.py). + +Live-testing after initial calibration found the home screen's event badge +(config.EVENT_BADGE_ICON) is itself a rotating carousel, not a stable +single-event slot as calibration happened to suggest: it cycles between the +current event's countdown AND other notices (e.g. an already-finished +event's remaining reward-claim-period reminder), so a single click can land +on a stale/wrong event page instead of the current one. _find_stage_row now +distinguishes "wrong page entirely" (no stage-row numbers OCR at all) from +"right page, this specific stage just isn't there" (some numbers found, just +not the target) -- the former retries via navigation.return_to_home + a +re-click of the badge, hoping the carousel has moved on by the next attempt; +the latter is reported and left alone, since retrying won't fix a genuinely +different stage list. """ import datetime @@ -36,6 +49,8 @@ OPEN_RETRIES = 3 POST_SWEEP_DISMISS_ROUNDS = 6 MAX_BUTTON_RETRIES = 3 MODAL_CLOSE_RETRIES = 3 +WRONG_PAGE_RETRIES = 3 +WRONG_PAGE_RETRY_WAIT = 3 def _is_stage_modal_open(driver, config): @@ -96,16 +111,25 @@ 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 # story_sweep._find_stage_row, only the bottom extreme is ever checked. + # + # Also tracks whether ANY row OCR'd a real number at all -- a finished/ + # stale event's Quest tab shows plain "period ended" text instead of + # 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." 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: - return row_y - return None + return row_y, saw_any_valid_row + return None, saw_any_valid_row def _click_max_and_verify(driver, config): @@ -174,8 +198,11 @@ def _watch_sweep_result(driver, config): def _sweep_target(driver, config, stage, count): print(f"[event_sweep] --- target stage {stage} x {count} ---") - row_y = _find_stage_row(driver, config, stage) + row_y, saw_any_valid_row = _find_stage_row(driver, config, stage) if row_y is None: + if not saw_any_valid_row: + print("[event_sweep] no stage-row numbers recognized at all -- likely on the wrong/stale event page") + return "wrong_page" print(f"[event_sweep] stage {stage} not found in the visible stage list") return "stage_not_found" @@ -237,16 +264,28 @@ def run(driver, config): stage, count = rotation print(f"[event_sweep] today's rotation target: stage {stage}") - if not _open_event_screen(driver, config): - print("[event_sweep] could not confirm event screen is open, aborting without pressing further keys") - return + outcome = None + for attempt in range(1, WRONG_PAGE_RETRIES + 1): + if not _open_event_screen(driver, config): + print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES})") + navigation.return_to_home(driver) + driver.wait(WRONG_PAGE_RETRY_WAIT) + continue - outcome = _sweep_target(driver, config, stage, count) - if outcome != "swept": + outcome = _sweep_target(driver, config, stage, count) + if outcome == "wrong_page": + print(f"[event_sweep] landed on the wrong event page (attempt {attempt}/{WRONG_PAGE_RETRIES}) -- returning home to retry") + navigation.return_to_home(driver) + driver.wait(WRONG_PAGE_RETRY_WAIT) + continue + break + else: + print(f"[event_sweep] could not reach the current event's stage list after {WRONG_PAGE_RETRIES} attempts, giving up") + + if outcome is not None and outcome not in ("swept", "wrong_page"): print(f"[event_sweep] target stage {stage} ended in '{outcome}'") - if navigation.is_on_subscreen(driver): - driver.click(*config.EVENT_BACK_BUTTON) - driver.wait(1) + if not navigation.return_to_home(driver): + print("[event_sweep] warning: could not confirm return to home screen") print("[event_sweep] Done.") diff --git a/plan.md b/plan.md index c8379e4..3f75c76 100644 --- a/plan.md +++ b/plan.md @@ -482,6 +482,17 @@ This is the same class of finding CLAUDE.md already documents from mailbox/cafe' **Not yet exercised**: an actual real sweep/AP spend (calibration deliberately avoided this, per `CLAUDE.md`'s "validate offline before spending the resource" guidance — the confirm dialog, cyan/gold color distinction, and result-screen mechanics were all visually confirmed but never clicked through to completion); the reference's SSS-availability gate for a stage that has never been cleared (every stage 9-12 on this account was already 3-starred, so `check_sweep_availability`'s "not yet sweepable, needs a manual fight first" branch was never seen — `event_sweep.py` handles this the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts that target without spending AP rather than guessing what an unsweepable stage's modal looks like); behavior once this event ends 2026-07-22 and a future event's badge/stage-count/layout replaces it (the 9-12 rotation range and row-position calibration are specific to this 12-stage event, not proven durable across arbitrary future events). +#### Phase 14 follow-up: wrong/stale event page fix + shared `navigation.return_to_home` + +**Reported by the user after a real run**: `event_sweep` opened an old, already-finished event's page instead of the current "鉄道爆走事件". Root cause: `config.EVENT_BADGE_ICON` (the home screen's top-right event badge) is itself a rotating carousel, not the stable single-event slot calibration happened to suggest — it cycles between the current event's own countdown and OTHER notices, including a finished event's leftover reward-claim-period reminder. Calibration's several screenshots all happened to catch it showing the right event, which masked this. + +**Fix, in two parts:** + +1. **New shared primitive**: `navigation.return_to_home(driver)` — a bounded "press Escape, re-check, fall back to clicking the shared back-arrow position, re-check" loop that returns once the home screen is confirmed reached (via `is_on_subscreen` reading `False`). Checks before every single press and never presses blind, matching the project's established "verify before acting" discipline — this specifically avoids the exit-game-confirmation hazard `CLAUDE.md` already documents (a stray Escape on the home screen itself raises Blue Archive's own "exit the game?" dialog). Generic across tasks: it only depends on `is_on_subscreen` and the shared back-arrow position (`navigation.BACK_BUTTON`, confirmed identical across `config.py`'s `LESSON_BACK_BUTTON`/`SHOP_BACK_BUTTON`/the now-removed `EVENT_BACK_BUTTON`), not on any event_sweep-specific state — built specifically so other tasks can reuse it later, per explicit user request ("make the back to home page a module so it can be used by other feature too"). +2. **Wrong-page detection + retry in `event_sweep.py`**: `_find_stage_row` now also reports whether it OCR'd *any* valid stage number at all across the 5 checked rows, not just whether the target matched. Zero valid reads across all 5 is a strong "not actually on a stage list, likely the wrong/finished event" signal (a finished event's Quest tab shows plain "period ended" text instead of stage-row cards) — reported as a distinct `"wrong_page"` outcome, separate from `"stage_not_found"` (right page, this specific stage just isn't visible, which retrying the same badge click won't fix). `run()` now retries the whole entry sequence up to `WRONG_PAGE_RETRIES` (3) times on a `wrong_page` outcome, calling `navigation.return_to_home` and waiting a few seconds before each re-click of the badge, hoping the carousel has moved on by the next attempt. `run()` also now unconditionally calls `navigation.return_to_home` at the very end as a final safety net, replacing the old single unconditional `EVENT_BACK_BUTTON` click. + +**Not yet live-tested**: this fix has only been syntax/compile-checked and deployed, not run against a real recurrence of the wrong-page bug — the retry timing (3 attempts, 3s apart) is a reasonable-but-unconfirmed guess at how fast the badge carousel actually cycles. + ## Prerequisites ### OCR