ba-auto-daily/ba_auto/navigation.py

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