"""Battle Pass (バトルパス) mission-point and level-reward claim. Ported from baas-reference's module/collect_pass_reward.py::implement(): main_page_to_pass_menu(self) # home -> pass main menu to_page_pass_mission(self) # pass main menu -> mission sub-screen collect_reward(self) # claim-all mission points to_page_pass_menu(self) # mission sub-screen -> pass main menu collect_reward(self) # claim-all pass-level reward detect_statistics(self) # OCR level/point/weekly-point readout detect_statistics is not ported: it only feeds the reference's own local stat-tracking file, and nothing in this project's flow consumes those numbers -- there is no OCR-driven decision anywhere in this task, so porting it would be unused OCR setup, not skipped OCR-driven navigation (the thing CLAUDE.md's OCR policy actually guards against). Reference navigation is template-image-driven via core/picture.py:: co_detect (img_possibles/img_ends against ~20 image assets this project doesn't have); the local equivalent signals are ported as color probes instead (config.BATTLE_PASS_*), the same substitution this project already uses for navigation.is_on_subscreen/is_modal_open. Those two generic probes do NOT fire correctly on either battle-pass screen -- both read exactly like true home (no bright subscreen header, no dark modal backdrop), confirmed live 2026-07-29 -- so this module carries its own local probes (BATTLE_PASS_INSIDE_PROBES/BATTLE_PASS_MISSION_TAB_PROBES) rather than reusing those, and explicitly presses Escape back to home at the end instead of relying on ba_daily.py's generic post-task navigation.return_to_home() cleanup, which would otherwise read the pass menu as "already home" and do nothing -- leaving the game stuck there for whatever task runs next in the same preset. The claim-all ("一括受取") button reuses stamina.py's exact bright- yellow-vs-grey enabled/disabled signature (same button style, confirmed live at this project's own coordinates) and its reward-reveal-popup dismiss convention (an extra Enter press after the claim press, harmless no-op if nothing is actually showing) -- both button instances on this task's two screens behaved the same way live, including a real level-up "報酬獲得!" card that needed that second Enter to clear. 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 def _claim_enabled(driver, config): r, g, b = driver.color_at(*config.BATTLE_PASS_CLAIM_PROBE) return r > 230 and (r - b) > 100 def _inside_battle_pass(driver, config): for r, g, b in driver.colors_at(config.BATTLE_PASS_INSIDE_PROBES): target_r, target_g, target_b = config.BATTLE_PASS_INSIDE_RGB tol = config.BATTLE_PASS_INSIDE_TOLERANCE if abs(r - target_r) > tol or abs(g - target_g) > tol or abs(b - target_b) > tol: return False return True def _on_mission_screen(driver, config): for r, g, b in driver.colors_at(config.BATTLE_PASS_MISSION_TAB_PROBES): if not (r > config.BATTLE_PASS_MISSION_TAB_MIN_CHANNEL and g > config.BATTLE_PASS_MISSION_TAB_MIN_CHANNEL and b > config.BATTLE_PASS_MISSION_TAB_MIN_CHANNEL): return False return True def _open_pass_menu(driver, config): for attempt in range(1, OPEN_RETRIES + 1): driver.click(*config.BATTLE_PASS_HOME_BANNER) driver.wait(2.5) # loading splash between home and the pass menu if _inside_battle_pass(driver, config): return True print(f"[battle_pass] pass menu not detected after click (attempt {attempt}/{OPEN_RETRIES})") return False def _open_mission_screen(driver, config): for attempt in range(1, MISSION_RETRIES + 1): driver.click(*config.BATTLE_PASS_MISSION_BUTTON) driver.wait(1.5) if _on_mission_screen(driver, config): return True print(f"[battle_pass] mission screen not detected after click (attempt {attempt}/{MISSION_RETRIES})") return False def _ensure_pass_menu(driver, config, max_attempts=3): """Confirm we're on the pass main menu specifically (not the mission sub-screen, not home), self-healing rather than just retrying Escape -- live-tested 2026-07-29 found Escape from the mission screen doesn't always land on the pass menu the way it did during calibration; one real run overshot straight past it to home instead (still an Escape-per-subscreen game, just not the fixed 2-level distance calibration assumed). If we're still on the mission screen, Escape again; if we've landed all the way back at home, re-open the pass menu from its home banner rather than giving up on the pass-level claim. """ for _ in range(max_attempts): if _inside_battle_pass(driver, config) and not _on_mission_screen(driver, config): return True if _on_mission_screen(driver, config): driver.keypress("Escape") driver.wait(1.5) continue if not _open_pass_menu(driver, config): return False return _inside_battle_pass(driver, config) and not _on_mission_screen(driver, config) def _claim_all(driver, config): claimed_any = False for _ in range(CLAIM_MAX_ROUNDS): if not _claim_enabled(driver, config): break print("[battle_pass] claiming reward") 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 def run(driver, config): driver.focus_game() if not _open_pass_menu(driver, config): print("[battle_pass] could not confirm the pass menu opened, aborting without pressing further keys") return if _open_mission_screen(driver, config): if not _claim_all(driver, config): print("[battle_pass] no mission rewards to claim") driver.keypress("Escape") driver.wait(1.5) else: print("[battle_pass] could not confirm the mission screen opened, skipping mission claim") if _ensure_pass_menu(driver, config): if not _claim_all(driver, config): print("[battle_pass] no pass-level reward to claim") else: print("[battle_pass] could not confirm the pass menu for the level-reward claim, skipping") 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)