309 lines
16 KiB
Python
309 lines
16 KiB
Python
"""Shared navigation helpers (home, menu, popups, back/escape)."""
|
|
|
|
from ba_auto import detector
|
|
|
|
# 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
|
|
|
|
# Home screen's own AP ("357/240"-style) display, in the header pill next to
|
|
# the lightning-bolt icon -- pixel-scanned live 2026-07-24 (x starts just
|
|
# clear of the icon, x2 leaves enough room for a 3-digit current value
|
|
# without reaching into the "+" button beyond it; tighter/looser variants
|
|
# tried on the same frame either clipped a digit or picked up stray
|
|
# icon/button pixels as spurious extra characters). Only meaningful on the
|
|
# true home screen -- every other subscreen's header has no AP display at
|
|
# all -- which is why the AP-floor guard in story_sweep.py/
|
|
# story_sweep_hard.py/event_sweep.py reads this immediately after
|
|
# driver.focus_game(), before any navigation away from home.
|
|
HOME_AP_OCR_RECT = (800, 30, 955, 72)
|
|
HOME_AP_READ_RETRIES = 3
|
|
|
|
# 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 current_ap(driver):
|
|
"""OCR the home screen's own AP display, returning the current value
|
|
only (not the max after the "/"), or None if unreadable after
|
|
HOME_AP_READ_RETRIES attempts.
|
|
|
|
Local port of the reference's Baas_thread.get_ap (is_main_page=True
|
|
branch) -- same split-on-"/"-and-parse-the-head approach as lesson.py's
|
|
own _read_ticket_count, just against the header's AP pill instead of the
|
|
lesson-ticket counter. Retries a transient None the same way
|
|
story_sweep._read_current_region does for this same header row.
|
|
"""
|
|
for attempt in range(1, HOME_AP_READ_RETRIES + 1):
|
|
text = detector.read_text(HOME_AP_OCR_RECT, whitelist="0123456789/", psm=7)
|
|
head = text.split("/")[0] if "/" in text else text
|
|
digits = "".join(ch for ch in head if ch.isdigit())
|
|
if digits:
|
|
return int(digits)
|
|
if attempt < HOME_AP_READ_RETRIES:
|
|
driver.wait(1)
|
|
return None
|
|
|
|
|
|
# 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
|
|
|
|
|
|
# Bottom nav bar's own flat near-white background band -- present ONLY on
|
|
# the true home screen (originally calibrated for login.py, see its module
|
|
# docstring for the two real false-positive frames this was hardened
|
|
# against: a bright loading-transition wipe, and a still-open news dialog).
|
|
# Promoted here (2026-07-31) because it is the only POSITIVE "genuinely
|
|
# home" signal in this project -- is_on_subscreen/is_modal_open are both
|
|
# negative checks, and battle_pass.py found live that BOTH read false/false
|
|
# on its own pass-menu/mission screens exactly like true home does (neither
|
|
# has a bright subscreen header or a dark modal backdrop). This band sits at
|
|
# y=1150, close under battle pass's own dark footer probe row (y=1190,
|
|
# config.BATTLE_PASS_INSIDE_PROBES) -- the footer band is dark/flat across
|
|
# that whole area, so this row reads correctly non-home while any battle
|
|
# pass screen is still open, unlike the ambiguous shared probes above.
|
|
HOME_NAV_BAR_PROBES = ((430, 1150), (720, 1150), (940, 1150), (1230, 1150), (1430, 1150))
|
|
HOME_NAV_BAR_MIN_CHANNEL = 220
|
|
HOME_NAV_BAR_MAX_SPREAD = 25
|
|
|
|
|
|
def is_home_nav_bar_visible(driver):
|
|
for r, g, b in driver.colors_at(HOME_NAV_BAR_PROBES):
|
|
if min(r, g, b) < HOME_NAV_BAR_MIN_CHANNEL:
|
|
return False
|
|
if max(r, g, b) - min(r, g, b) > HOME_NAV_BAR_MAX_SPREAD:
|
|
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.
|
|
|
|
The escalation call itself now has the same window_exists() guard,
|
|
added 2026-07-20 after a real crash: the entry check above only covers
|
|
the window being gone at the START of this function, not it
|
|
disappearing mid-loop. Confirmed live via a real q4h cron run's
|
|
traceback -- exit_game closed the game and printed its own "closed"
|
|
confirmation, then ba_daily.py's post-task cleanup called this function
|
|
immediately afterward with zero delay; driver.window_exists() still
|
|
read True at that exact instant (the game's own teardown apparently
|
|
isn't instantaneous -- the window can take a moment to fully
|
|
disappear from xdotool's search after the in-game exit is confirmed),
|
|
so the entry guard passed and the retry loop began pressing
|
|
Escape/BACK_BUTTON against a game that was already exiting. By the
|
|
time the loop reached its halfway escalation point a few seconds
|
|
later, the window had fully disappeared for real, and the unguarded
|
|
driver.focus_game() call crashed the whole script with an uncaught
|
|
RuntimeError. Fixed by checking window_exists() again right at the
|
|
escalation point too (matching click_back's own already-established
|
|
convention below), returning False instead of crashing if the window
|
|
is gone by then -- this is a general robustness fix benefiting any
|
|
task's cleanup path where the window can disappear mid-retry, not just
|
|
exit_game's.
|
|
"""
|
|
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
|
|
if not driver.window_exists():
|
|
return False
|
|
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
|