diff --git a/ba_auto/config.py b/ba_auto/config.py index b1b6d92..56e0c3f 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -772,6 +772,18 @@ SHOP_CHECKED_RGB = ((60, 130, 80), (220, 245, 115)) # for the largest configured price without bleeding into the neighboring # column's card. 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_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 # captured state (both title-screen background arts, the attendance card, # 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)) -LOGIN_HOME_NAV_BAR_MIN_CHANNEL = 220 -LOGIN_HOME_NAV_BAR_MAX_SPREAD = 25 +# Promoted to navigation.is_home_nav_bar_visible/HOME_NAV_BAR_* (2026-07-31) +# once battle_pass.py needed the same positive signal for the same reason +# -- 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 # Generous per-attempt budget: a normal run (title tap -> loading -> diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index e699517..2364599 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -95,6 +95,33 @@ def is_header_bar_visible(driver): 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 diff --git a/ba_auto/tasks/battle_pass.py b/ba_auto/tasks/battle_pass.py index c077b45..ae9e505 100644 --- a/ba_auto/tasks/battle_pass.py +++ b/ba_auto/tasks/battle_pass.py @@ -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 here spends AP/credits/tickets, so it belongs in ba_daily.py's 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 MISSION_RETRIES = 3 CLAIM_MAX_ROUNDS = 3 +DISMISS_EXTRA_PRESSES = 2 EXIT_MAX_ROUNDS = 4 @@ -123,11 +148,19 @@ def _claim_all(driver, config): print("[battle_pass] claiming reward") driver.keypress("Return") driver.wait(1.5) - # dismiss the reward-reveal ("報酬獲得!"/TOUCH) popup; harmless - # no-op if nothing is actually showing, same assumption as - # stamina.py's identical second press - driver.keypress("Return") - driver.wait(1.5) + # Dismiss the reward-reveal ("報酬獲得!"/TOUCH) popup -- pressed + # DISMISS_EXTRA_PRESSES times rather than once, each a harmless + # no-op if nothing is actually showing (same assumption as + # stamina.py's own single extra press). Widened to more than one + # 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 return claimed_any @@ -153,10 +186,25 @@ def run(driver, config): else: print("[battle_pass] could not confirm the pass menu for the level-reward claim, skipping") - for _ in range(EXIT_MAX_ROUNDS): - if not _inside_battle_pass(driver, config): - break - driver.keypress("Escape") - driver.wait(1.5) + if not _exit_to_home(driver, config): + print("[battle_pass] warning: could not confirm return to home screen after exiting the pass menu") 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) diff --git a/ba_auto/tasks/login.py b/ba_auto/tasks/login.py index 48e6afa..81fbe2c 100644 --- a/ba_auto/tasks/login.py +++ b/ba_auto/tasks/login.py @@ -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) -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): 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_modal_open(driver) and not _news_dialog_open(driver, config) diff --git a/ba_auto/tasks/shop_utils.py b/ba_auto/tasks/shop_utils.py index 1e8ff30..1d9604f 100644 --- a/ba_auto/tasks/shop_utils.py +++ b/ba_auto/tasks/shop_utils.py @@ -42,11 +42,25 @@ def select_targets(driver, config, targets): it registers as checked. Returns (selected, skipped); a price mismatch or an unconfirmed checkbox skips just that target rather than aborting 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 = [] skipped = [] 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: print(f"[shop] '{name}' price read as {price}, expected {expected_price} -- skipping (catalog may have changed)") skipped.append((name, "price_mismatch"))