feat(exit_game): implement task to exit the Blue Archive client after daily/q4h runs

This commit is contained in:
Nik Afiq 2026-07-18 20:25:32 +09:00
parent a30c3888d6
commit 0a6af51340
4 changed files with 110 additions and 4 deletions

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,73 @@
"""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().
Not yet live-tested: navigation.is_modal_open's generic dim-probe hasn't
been confirmed specifically against this dialog's own visual layout.
"""
from ba_auto import navigation
CONFIRM_RETRIES = 3
CLOSE_WAIT_ITERATIONS = 15
CLOSE_WAIT_INTERVAL = 1
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
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.")

View File

@ -3,7 +3,7 @@
import sys
from ba_auto import config, driver, navigation
from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep
from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep
TASKS = {
"login": login.run,
@ -19,6 +19,7 @@ TASKS = {
"lesson": lesson.run,
"arena": arena.run,
"bounty": bounty.run,
"exit_game": exit_game.run,
}
# story_sweep, event_sweep, both shop tasks, lesson, arena, and bounty are
# opt-in only (not in the default flow): they spend AP/credits/tactical
@ -51,14 +52,21 @@ DEFAULT_ORDER = ["login", "mailbox", "cafe", "stamina", "gem_shop", "circle"]
# instead, matching CLAUDE.md's "Python owns automation logic" rule, and
# `_run_task`'s existing try/finally means an uncaught exception from one
# task still halts the whole sequence exactly like the bash `|| return` did.
# exit_game is appended to the end of both presets below -- it deliberately
# ends the session (Escape then Enter on the confirmed true home screen
# raises and confirms Blue Archive's own "exit the game?" dialog), so like
# every other resource-spending/session-affecting task it's opt-in only, not
# in DEFAULT_ORDER. Not yet live-tested -- see
# ba_auto/reference_notes/mapping.md's "Exit game" row before trusting it
# unattended.
PRESETS = {
"daily": [
"login", "event_sweep", "cafe", "event_sweep", "circle", "lesson",
"arena", "shop_common", "shop_tactical", "event_sweep", "bounty",
"gem_shop", "mailbox", "stamina", "event_sweep",
"gem_shop", "mailbox", "stamina", "event_sweep", "exit_game",
],
"q4h": [
"login", "cafe", "mailbox", "stamina", "event_sweep"
"login", "cafe", "mailbox", "stamina", "event_sweep", "exit_game"
]
}
@ -142,13 +150,23 @@ def _run_task(name):
afterward rather than being swallowed -- a crash should still surface
as a crash, just with the game safely back at home first instead of
stuck wherever it failed.
The post-task check is skipped entirely (no call, no warning) if the
game window no longer exists at all -- added alongside exit_game, the
first task able to legitimately close it. Without this, exit_game
succeeding would still make navigation.return_to_home return False (it
already bails out gracefully on a missing window, see its own
docstring) and print a "could not confirm return to home" warning on
every single daily/q4h run that reaches it, even though nothing is
actually wrong -- exactly the kind of noise the cron log convention
(`grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log`) is meant to avoid.
"""
if not _ensure_home(name):
print(f"[{name}] warning: could not confirm starting from the home screen after {PRE_TASK_HOME_RETRIES} attempts -- proceeding anyway")
try:
TASKS[name](driver, config)
finally:
if not navigation.return_to_home(driver):
if driver.window_exists() and not navigation.return_to_home(driver):
print(f"[{name}] warning: could not confirm return to home screen after task finished")

14
plan.md
View File

@ -902,6 +902,20 @@ The stuck session itself was cleared with a manual kill+relaunch (the user's own
Not yet re-confirmed: a fresh instance of the *original* reported failure mode specifically -- a session that silently dies while still looking exactly like home, discovered only when a later task tries to navigate (the cron-log scenario). The live reproduction available this session was the full-kick-to-login-screen variant (triggered by the phone login), not that exact "looks fine, breaks on navigation" variant -- though `_connectivity_confirmed` is designed to catch both by the same mechanism (a real API round-trip, not a visual check), and the false positives it exposed and fixed along the way are a strict improvement regardless.
### Phase 19: Exit game (2026-07-18)
User request: a task to exit the Blue Archive client, meant to run after the `daily`/`q4h` presets finish, via Escape then Enter.
Searched `~/repo/baas-reference/` thoroughly first, per the reference-first workflow -- no counterpart exists. The closest match, `core/Baas_thread.py`'s `shutdown()`/`start_shutdown()` (line ~984), is an optional full Windows OS shutdown (`subprocess.run(["shutdown", "-s", "-t", "60"])`) gated by a user config toggle, not a game-client exit -- out of scope, since this project's game and X display run on `nik-gpu` over the same SSH-adjacent session this automation itself depends on. The only other app-closing call, `connection.py`'s `close_current_app` (an ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts inside a persistent background scheduler designed to run forever -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic for this project's different architecture (cron launches a fresh one-shot process per preset, so actually closing the game afterward has value the reference's always-on scheduler never needed), not a port of anything -- see `ba_auto/reference_notes/mapping.md`'s "Exit game" row for the full writeup.
Implemented `ba_auto/tasks/exit_game.py` reusing only existing primitives, no new driver/detector code: `navigation.return_to_home` (must confirm true home before doing anything -- `return_to_home`'s own docstring already documents that a stray Escape at home raises this exact dialog, a hazard every OTHER task avoids; this is the one task that wants it, so it only fires from a verified state), `navigation.is_modal_open` (verifies the dialog actually opened after Escape, reusing the same generic dim-probe bounty/gem_shop/circle's own confirm dialogs already use), `driver.keypress` (Escape to raise, Return to confirm -- this project's established confirm key), and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent). No force-kill fallback if the graceful path doesn't verify, matching this project's "abort cleanly on unknown state" convention (gem_shop/bounty) over reaching for `driver.kill_game()`.
Wired in as opt-in only (added `exit_game` to `TASKS` in `ba_daily.py`), appended to the end of both the `daily` and `q4h` presets -- not `DEFAULT_ORDER`, since it deliberately ends the session.
One real bug found and fixed before any live test, while reasoning through what happens next: `ba_daily.py`'s centralized `_run_task` always calls `navigation.return_to_home` in its post-task `finally` block and prints a warning if it returns False. `return_to_home` already bails out gracefully (returns False, doesn't crash) when the window doesn't exist, added for login.py's cold-start case -- but that means after `exit_game` succeeds, this same finally-block would print a misleading "could not confirm return to home screen after task finished" warning on every single `daily`/`q4h` run that reaches it, even though nothing is wrong. Fixed by skipping the post-task check entirely when `driver.window_exists()` is already False.
**Not yet live-tested.** `navigation.is_modal_open`'s generic dim-probe has not been specifically confirmed against this dialog's own visual layout -- needs a real run to confirm the Escape press actually raises it and that the dim-probe correctly reads it as open, before trusting this unattended in cron. Get explicit go-ahead before the first live test, since it deliberately ends a real game session.
## Prerequisites
### OCR