ba-auto-daily/ba_auto/navigation.py
Nik Afiq c5873f4e58 Refactor arena and lesson tasks for improved efficiency and reliability
- Updated arena.py to allow multiple battles per invocation, looping until tickets are exhausted or a rank-1 condition is met. Introduced _fight_one function for single battle logic and added cooldown handling between fights.
- Enhanced lesson.py to implement a tiered priority system for scheduling lessons based on student slots available, replacing the previous highest affection value selection. Introduced functions for scanning all regions and building a priority queue for lesson scheduling.
- Centralized return-to-home logic in ba_daily.py to ensure the game returns to the home screen before and after each task, improving robustness against navigation issues.
- Added retry mechanism for returning to home, allowing for transient navigation issues to be handled gracefully without aborting tasks.
2026-07-13 10:32:53 +09:00

121 lines
5.7 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.
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 _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)
return not _not_home(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