"""Shared navigation helpers (home, menu, popups, back/escape).""" # The mailbox/cafe/shop-style header bar renders a plain light background # here; the home screen shows character art instead. SUBSCREEN_HEADER_PROBE = (500, 10) SUBSCREEN_HEADER_MIN_CHANNEL = 200 # Any modal dialog dims the screen behind it to roughly this darkness. 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) return r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL def is_modal_open(driver): r, g, b = driver.color_at(*MODAL_DIM_PROBE) 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 reference's core/picture.py::co_detect, scoped to what this project actually needs (a handful of named checks) rather than co_detect's full generality (which spans the whole reference project via ~20 image template assets this project doesn't have). `ends`: {check_fn(driver, config) -> bool: outcome_name}. Checked first, every iteration; the first match stops the loop and returns its name. `reactions`: {check_fn(driver, config) -> bool: action(driver)}. Checked if no end matched; the first match runs its action (a click, a keypress, whatever the recognized state calls for) and the loop continues. If neither an end nor a reaction matches, the loop just waits and retries -- it never falls back to a blind click/keypress guess (see CLAUDE.md's exit-game-dialog writeup for why that was a real bug elsewhere). Returns the matched end's outcome name, or None once max_iterations is exhausted without reaching a recognized end -- callers should treat None as "unrecognized state, abort safely." """ for _ in range(max_iterations): for check_fn, outcome_name in ends.items(): if check_fn(driver, config): return outcome_name for check_fn, action in reactions.items(): if check_fn(driver, config): action(driver) break else: driver.wait(poll_interval) return None