fix(event_sweep): implement wrong/stale event page detection and shared navigation return to home functionality

This commit is contained in:
Nik Afiq 2026-07-10 12:33:22 +09:00
parent 1b313d27ff
commit af0a784bcd
5 changed files with 118 additions and 17 deletions

View File

@ -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 /

View File

@ -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

View File

@ -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 |

View File

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

11
plan.md
View File

@ -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