- Added `login.py` to handle the login process, including detection of various states such as the title screen, network error notices, loading transitions, daily attendance cards, and the home screen. - Integrated a recovery mechanism to relaunch the game if it gets stuck during loading. - Updated `ba_daily.py` to include the login task in the default execution order, ensuring it runs first to navigate to the home screen before other tasks. - Fixed a bug in the home detection logic to accurately identify when the user is on the true home screen. - Live-tested and calibrated the login flow against real game states, confirming functionality and addressing any issues encountered during testing.
221 lines
11 KiB
Python
221 lines
11 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
|
|
|
|
|
|
# Every confirmed subscreen (mailbox/cafe/shop/lesson/event) renders a
|
|
# uniform light header bar spanning nearly the full screen width at this y --
|
|
# is_on_subscreen only samples one x on that row (SUBSCREEN_HEADER_PROBE),
|
|
# which is cheap and has been fine for ordinary subscreen-vs-home checks, but
|
|
# was found live (2026-07-15, cafe.py's rank-up dismiss loop) to misread a
|
|
# full-screen "絆ランクアップ!" cutscene as "already back on subscreen" for
|
|
# some characters -- the single x=500 sample happened to land on a bright
|
|
# patch of that character's own art/background, not the real header. A real
|
|
# header bar is flat and uniform across its whole width; a photo-real
|
|
# cutscene composition (hair, uniform, the dark rank-up banner itself) is
|
|
# very unlikely to coincidentally read bright at MANY spread-out x offsets on
|
|
# the same row simultaneously. Used where that specific ambiguity matters
|
|
# (so far only cafe.py's rank-up dismiss) rather than swapped in for
|
|
# is_on_subscreen everywhere, to avoid changing already-working behavior at
|
|
# every other call site.
|
|
HEADER_ROW_Y = 10
|
|
HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700)
|
|
|
|
|
|
def is_header_bar_visible(driver):
|
|
# One screenshot for all 8 points (driver.colors_at) rather than 8
|
|
# separate color_at() calls -- cheaper, and atomic (every point comes
|
|
# from the same frame instead of drifting across ~8 sequential captures).
|
|
points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS]
|
|
for r, g, b in driver.colors_at(points):
|
|
if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL):
|
|
return False
|
|
return True
|
|
|
|
|
|
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.
|
|
|
|
Bails out immediately (False) if the game window doesn't exist at all,
|
|
rather than pressing/escalating into a guaranteed crash: the escalation
|
|
step below calls driver.focus_game(), which raises RuntimeError if it
|
|
can't find the window -- a real crash hit live (2026-07-16) when
|
|
ba_daily.py's centralized pre-task _ensure_home() called this function
|
|
before login.py (the one task able to launch the game itself, see its
|
|
own module docstring) ever got a chance to run. Every OTHER task still
|
|
crashes as before via its own driver.focus_game() call once dispatched
|
|
-- correct for them, since they have no ability to start the game --
|
|
this only changes behavior for the specific "no window yet" case this
|
|
function itself can't do anything about anyway.
|
|
"""
|
|
if not driver.window_exists():
|
|
return False
|
|
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 and driver.window_exists():
|
|
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
|