"""Exit the Blue Archive client. No baas-reference counterpart -- see ba_auto/reference_notes/mapping.md's "Exit game" row for the full search: the reference project runs as a persistent background scheduler and never deliberately exits the app between task cycles. This is new, desktop-specific convenience logic for this project's different (cron, one-shot-per-preset) architecture, meant to run as the last task in a preset. navigation.return_to_home's own docstring already documents the mechanism this relies on: an Escape press on the confirmed true home screen (nowhere else) raises Blue Archive's own "exit the game?" confirmation dialog. Every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, so it only fires Escape after verifying true home first, never blindly. Enter is this project's established confirm/OK key (circle.py/gem_shop.py's own reward dismissal). Success is verified against driver.window_exists() actually going False, not just against the keypress having been sent, matching every other verified-keypress helper in this project. No force-kill fallback if the graceful path doesn't verify -- matches this project's "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty) rather than reaching for driver.kill_game(). Confirmed live (2026-07-18) via a standalone `exit_game` run: the is_modal_open dim-probe correctly detected the exit-confirmation dialog after Escape, and the game closed cleanly after Enter. Not yet exercised as the tail end of a full daily/q4h preset run specifically. """ from ba_auto import navigation CONFIRM_RETRIES = 3 CLOSE_WAIT_ITERATIONS = 15 CLOSE_WAIT_INTERVAL = 1 def _alert_if_ap_left_unspent(driver, config): """Print a distinctly-tagged, greppable log line if AP is still above config.EXIT_AP_ALERT_THRESHOLD right as the game is about to close -- ba_cron_run.sh greps its own captured run output for the "AP_ALERT:" tag and fires an extra Discord alert on it (see plan.md Phase 30). Alert-only: never blocks or delays the exit itself. Must run while still confirmed on true home (navigation.current_ap's OCR rect is only meaningful there, same precondition already documented on that function). An unreadable OCR result (None) is skipped silently, matching this project's "don't guess on unreadable OCR" convention rather than alerting on a possibly-wrong value. """ ap = navigation.current_ap(driver) if ap is not None and ap > config.EXIT_AP_ALERT_THRESHOLD: print(f"[exit_game] AP_ALERT: exiting with {ap} AP unspent (threshold {config.EXIT_AP_ALERT_THRESHOLD})") def _raise_exit_dialog(driver, config): for attempt in range(1, CONFIRM_RETRIES + 1): if attempt == CONFIRM_RETRIES and driver.window_exists(): driver.focus_game() driver.keypress("Escape") driver.wait(1) if navigation.is_modal_open(driver): return True print(f"[exit_game] exit-confirmation dialog not detected after Escape (attempt {attempt}/{CONFIRM_RETRIES})") return False def _confirm_exit(driver, config): for attempt in range(1, CONFIRM_RETRIES + 1): driver.keypress("Return") driver.wait(1) for _ in range(CLOSE_WAIT_ITERATIONS): if not driver.window_exists(): return True driver.wait(CLOSE_WAIT_INTERVAL) print(f"[exit_game] game window still present after Enter (attempt {attempt}/{CONFIRM_RETRIES})") return not driver.window_exists() def run(driver, config): driver.focus_game() if not navigation.return_to_home(driver): print("[exit_game] could not confirm the true home screen -- aborting without pressing Escape") return _alert_if_ap_left_unspent(driver, config) if not _raise_exit_dialog(driver, config): print("[exit_game] could not confirm the exit-confirmation dialog opened -- aborting without pressing Enter") return if not _confirm_exit(driver, config): print("[exit_game] warning: could not confirm the game actually closed") return print("[exit_game] game closed.")