- 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.
121 lines
5.7 KiB
Python
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
|