feat: enhance navigation and shop utilities with improved visibility checks and price reading retries

This commit is contained in:
Nik Afiq 2026-07-31 13:48:25 +09:00
parent ffcacc8738
commit a15ac6111e
5 changed files with 117 additions and 24 deletions

View File

@ -772,6 +772,18 @@ SHOP_CHECKED_RGB = ((60, 130, 80), (220, 245, 115))
# for the largest configured price without bleeding into the neighboring # for the largest configured price without bleeding into the neighboring
# column's card. # column's card.
SHOP_PRICE_OCR_OFFSET = (48, 166, 145, 195) SHOP_PRICE_OCR_OFFSET = (48, 166, 145, 195)
# A real live run (script_error.md, 2026-07-31) misread '中級レポート's
# price as 1250060 instead of 125000 -- a stray extra digit, not a
# systematically wrong crop (every other target, including three other
# 6-digit prices in this same list, read correctly in that same run, and
# re-summing the run's own "bought N item(s) for TOTAL credits" log against
# every OTHER target's price confirms it). One-off tesseract flake, same
# class of transient misread this project already retries elsewhere (see
# driver.read_screenshot's decode-retry doc) rather than a calibration
# problem -- so shop_utils.select_targets re-reads the price up to this
# many times, taking the first read that matches expected_price, before
# concluding the catalog actually changed.
SHOP_PRICE_OCR_RETRIES = 3
SHOP_BUY_BUTTON = (1751, 1112) SHOP_BUY_BUTTON = (1751, 1112)
SHOP_CANCEL_BUTTON = (1525, 1112) SHOP_CANCEL_BUTTON = (1525, 1112)
@ -1479,9 +1491,10 @@ LOGIN_NEWS_HEADER_TOLERANCE = 35
# was confirmed live to uniquely hold on true home and fail on every other # was confirmed live to uniquely hold on true home and fail on every other
# captured state (both title-screen background arts, the attendance card, # captured state (both title-screen background arts, the attendance card,
# and home with the news dialog still open and dimming this same area). # and home with the news dialog still open and dimming this same area).
LOGIN_HOME_NAV_BAR_PROBES = ((430, 1150), (720, 1150), (940, 1150), (1230, 1150), (1430, 1150)) # Promoted to navigation.is_home_nav_bar_visible/HOME_NAV_BAR_* (2026-07-31)
LOGIN_HOME_NAV_BAR_MIN_CHANNEL = 220 # once battle_pass.py needed the same positive signal for the same reason
LOGIN_HOME_NAV_BAR_MAX_SPREAD = 25 # -- see that module's docstring. Matches navigation.py's own convention of
# owning its probe constants locally rather than sourcing them from here.
LOGIN_POLL_INTERVAL = 2 LOGIN_POLL_INTERVAL = 2
# Generous per-attempt budget: a normal run (title tap -> loading -> # Generous per-attempt budget: a normal run (title tap -> loading ->

View File

@ -95,6 +95,33 @@ def is_header_bar_visible(driver):
return True 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): def _not_home(driver):
# "Home" means neither on a subscreen NOR under an open modal. Checking # "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 # only is_on_subscreen was found live (2026-07-11, ba_daily.py's

View File

@ -41,11 +41,36 @@ task's two screens behaved the same way live, including a real level-up
This is a pure reclaim like mailbox/cafe/stamina/gem_shop/circle: nothing This is a pure reclaim like mailbox/cafe/stamina/gem_shop/circle: nothing
here spends AP/credits/tickets, so it belongs in ba_daily.py's here spends AP/credits/tickets, so it belongs in ba_daily.py's
DEFAULT_ORDER rather than staying opt-in. DEFAULT_ORDER rather than staying opt-in.
**Real bug, found from a live `daily` preset run (2026-07-31, see
script_error.md)**: the exit loop below used to stop as soon as
_inside_battle_pass(driver, config) read False, with no further
verification and no failure print. That is a NEGATIVE condition only -- it
proves we're no longer showing battle pass's own dark footer band, not that
we've actually reached true home. A run landed on event_sweep straight
after battle_pass with three consecutive "event screen not detected after
click" retries and no warning logged in between, meaning both this task's
own exit loop AND ba_daily.py's generic post-task navigation.return_to_home
cleanup (which the module docstring above already documents can't tell the
pass menu apart from home either) silently agreed we were home when we
almost certainly weren't -- most likely a still-open reward-reveal card
(e.g. a level-up bundling more than one item, needing more than the single
extra dismiss Enter _claim_all already sends) darkening/covering that
footer probe just enough to misread as "left". Fixed by also requiring
navigation.is_home_nav_bar_visible(driver) -- the same positive bottom-nav-
bar signal login.py needed for the identical class of false-positive -- and
printing a clear warning if the loop still can't confirm true home after
EXIT_MAX_ROUNDS, so a stuck battle pass screen shows up in logs instead of
masquerading as "Done." Not yet re-confirmed live against a real repeat of
this exact stuck state (would need a real level-up to reproduce on demand).
""" """
from ba_auto import navigation
OPEN_RETRIES = 3 OPEN_RETRIES = 3
MISSION_RETRIES = 3 MISSION_RETRIES = 3
CLAIM_MAX_ROUNDS = 3 CLAIM_MAX_ROUNDS = 3
DISMISS_EXTRA_PRESSES = 2
EXIT_MAX_ROUNDS = 4 EXIT_MAX_ROUNDS = 4
@ -123,11 +148,19 @@ def _claim_all(driver, config):
print("[battle_pass] claiming reward") print("[battle_pass] claiming reward")
driver.keypress("Return") driver.keypress("Return")
driver.wait(1.5) driver.wait(1.5)
# dismiss the reward-reveal ("報酬獲得!"/TOUCH) popup; harmless # Dismiss the reward-reveal ("報酬獲得!"/TOUCH) popup -- pressed
# no-op if nothing is actually showing, same assumption as # DISMISS_EXTRA_PRESSES times rather than once, each a harmless
# stamina.py's identical second press # no-op if nothing is actually showing (same assumption as
driver.keypress("Return") # stamina.py's own single extra press). Widened to more than one
driver.wait(1.5) # after a real stuck run (see this module's docstring) where the
# leading suspect is a level-up bundling more than one item into
# separate stacked cards, which a single dismiss press wouldn't
# fully clear -- leaving a card open that the next round's
# _claim_enabled check (covered by that same card) would then
# misread as "nothing left to claim" rather than "still blocked".
for _ in range(DISMISS_EXTRA_PRESSES):
driver.keypress("Return")
driver.wait(1.2)
claimed_any = True claimed_any = True
return claimed_any return claimed_any
@ -153,10 +186,25 @@ def run(driver, config):
else: else:
print("[battle_pass] could not confirm the pass menu for the level-reward claim, skipping") print("[battle_pass] could not confirm the pass menu for the level-reward claim, skipping")
for _ in range(EXIT_MAX_ROUNDS): if not _exit_to_home(driver, config):
if not _inside_battle_pass(driver, config): print("[battle_pass] warning: could not confirm return to home screen after exiting the pass menu")
break
driver.keypress("Escape")
driver.wait(1.5)
print("[battle_pass] Done.") print("[battle_pass] Done.")
def _confirmed_home(driver, config):
# Both conditions matter: _inside_battle_pass alone is a negative check
# (only proves the pass-menu footer band is gone, not that true home was
# actually reached -- see this module's docstring for the real stuck-run
# this fooled), and is_home_nav_bar_visible alone was calibrated against
# login.py's pre-login states, not battle pass's own screens.
return not _inside_battle_pass(driver, config) and navigation.is_home_nav_bar_visible(driver)
def _exit_to_home(driver, config):
for _ in range(EXIT_MAX_ROUNDS):
if _confirmed_home(driver, config):
return True
driver.keypress("Escape")
driver.wait(1.5)
return _confirmed_home(driver, config)

View File

@ -281,18 +281,9 @@ def _loading_buffer_visible(driver, config):
return all(_color_matches(c, config.LOGIN_LOADING_BUFFER_RGB, config.LOGIN_LOADING_BUFFER_TOLERANCE) for c in colors) return all(_color_matches(c, config.LOGIN_LOADING_BUFFER_RGB, config.LOGIN_LOADING_BUFFER_TOLERANCE) for c in colors)
def _home_nav_bar_visible(driver, config):
for r, g, b in driver.colors_at(config.LOGIN_HOME_NAV_BAR_PROBES):
if min(r, g, b) < config.LOGIN_HOME_NAV_BAR_MIN_CHANNEL:
return False
if max(r, g, b) - min(r, g, b) > config.LOGIN_HOME_NAV_BAR_MAX_SPREAD:
return False
return True
def _true_home(driver, config): def _true_home(driver, config):
return ( return (
_home_nav_bar_visible(driver, config) navigation.is_home_nav_bar_visible(driver)
and not navigation.is_on_subscreen(driver) and not navigation.is_on_subscreen(driver)
and not navigation.is_modal_open(driver) and not navigation.is_modal_open(driver)
and not _news_dialog_open(driver, config) and not _news_dialog_open(driver, config)

View File

@ -42,11 +42,25 @@ def select_targets(driver, config, targets):
it registers as checked. Returns (selected, skipped); a price mismatch it registers as checked. Returns (selected, skipped); a price mismatch
or an unconfirmed checkbox skips just that target rather than aborting or an unconfirmed checkbox skips just that target rather than aborting
the whole run (mirrors story_sweep's per-target skip-not-abort design). the whole run (mirrors story_sweep's per-target skip-not-abort design).
Re-reads the price up to config.SHOP_PRICE_OCR_RETRIES times, stopping
early on the first read that matches expected_price -- a real live run
misread one target's price by a single stray digit while every other
target (including other 6-digit prices) read correctly in the same run,
so a single bad frame shouldn't skip a real purchase. A genuine catalog
change still reads the same (wrong) value across every retry and gets
skipped as before. See config.SHOP_PRICE_OCR_RETRIES's own comment.
""" """
selected = [] selected = []
skipped = [] skipped = []
for row, col, name, expected_price in targets: for row, col, name, expected_price in targets:
price = detector.read_int(_price_rect(config, row, col)) price = None
for attempt in range(1, config.SHOP_PRICE_OCR_RETRIES + 1):
price = detector.read_int(_price_rect(config, row, col))
if price == expected_price:
break
if attempt < config.SHOP_PRICE_OCR_RETRIES:
driver.wait(0.3)
if price != expected_price: if price != expected_price:
print(f"[shop] '{name}' price read as {price}, expected {expected_price} -- skipping (catalog may have changed)") print(f"[shop] '{name}' price read as {price}, expected {expected_price} -- skipping (catalog may have changed)")
skipped.append((name, "price_mismatch")) skipped.append((name, "price_mismatch"))