ba-auto-daily/ba_auto/navigation.py

177 lines
8.6 KiB
Python

"""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 _not_home(driver):
# "Home" means neither on a subscreen NOR under an open modal. Checking
# only is_on_subscreen was found live (2026-07-11, ba_daily.py's
# centralized return-to-home audit) to be unsound whenever a modal is
# open on top of a subscreen: the modal's own screen-wide dimming
# overlay darkens SUBSCREEN_HEADER_PROBE right along with everything
# else, so it reads exactly like the true home screen's own dark
# header art -- is_on_subscreen returns False in BOTH cases, and
# return_to_home was mistaking "subscreen with a stuck modal open" for
# "home reached" and stopping immediately without ever pressing
# anything. Confirmed via direct pixel comparison: a real stuck event
# stage-info modal read (80,81,82) at SUBSCREEN_HEADER_PROBE (fails the
# >200 check, same as home), while MODAL_DIM_PROBE correctly read dark
# there (51,37,28) and correctly read NOT dark on the true home screen
# (126,136,210) -- so checking both together tells the two apart.
return is_on_subscreen(driver) or is_modal_open(driver)
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/is_modal_open and the
shared BACK_BUTTON position above, not on any task-specific state.
Checks _not_home 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. Escape maps to
Cancel on that confirmation dialog too (confirmed live across every
dialog in this project), so even a wrongly-timed press against it is
safe -- it never presses Enter/OK.
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.
Halfway through the round budget, if neither has made any progress,
re-raises the game window once via driver.focus_game() before
continuing. Confirmed live (2026-07-12, a bounty run): a stray
anti-cheat XIGNCODE window can render visibly on top of the game and
silently intercept every Escape/BACK_BUTTON press at that exact screen
position -- xdotool still reports BlueArchive as the "active" window
throughout, so nothing here can detect the overlay directly, only that
presses keep not working. windowactivate (already called at the start
of every task via driver.focus_game()) doesn't fix this -- it changes
input focus, not stacking order -- but windowraise does, confirmed live
by reproducing the exact stuck state and watching return_to_home
immediately succeed once the window was raised. driver.focus_game()
itself now calls windowraise too, so this escalation is a second,
later-in-the-budget safety net for the case an overlay appears or
becomes input-stealing mid-run, after focus_game()'s own start-of-task
call already ran.
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.
"""
escalated = False
for round_num in range(1, RETURN_HOME_MAX_ROUNDS + 1):
if not _not_home(driver):
return True
driver.keypress("Escape")
driver.wait(1)
if not _not_home(driver):
return True
driver.click(*BACK_BUTTON)
driver.wait(1)
if not _not_home(driver):
return True
if not escalated and round_num >= RETURN_HOME_MAX_ROUNDS // 2:
escalated = True
print("[navigation] still not home halfway through recovery -- re-raising the game window in case a stray overlay (e.g. XIGNCODE) is intercepting input")
driver.focus_game()
return not _not_home(driver)
def click_back(driver, verify_fn, max_attempts=3):
"""Click the shared BACK_BUTTON with retry, for callers that need to go
back exactly ONE level (e.g. a per-region map -> its list screen) rather
than return_to_home's "all the way to home" semantics -- so it can't
just reuse return_to_home directly, even though it wants the same
click-then-verify-then-retry robustness.
`verify_fn(driver)` should return True once the click's intended effect
is confirmed (whatever that means for the caller's own screen
transition). Escalates once, via driver.focus_game() (re-raising the
game window), right before the FINAL attempt if every earlier one
failed -- the same XIGNCODE-overlay defense return_to_home has (a stray
anti-cheat window can silently intercept clicks at this exact shared
coordinate even while the game reports itself as the active window; see
return_to_home's own docstring for the live-confirmed history). Added
2026-07-12 after an audit found lesson.py's own back-button clicks had
no retry or overlay defense at all, unlike return_to_home.
Returns True once verify_fn confirms success, False if still
unconfirmed after max_attempts.
"""
for attempt in range(1, max_attempts + 1):
if attempt == max_attempts:
driver.focus_game()
driver.click(*BACK_BUTTON)
driver.wait(1.5)
if verify_fn(driver):
return True
return verify_fn(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