diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 80e834f..8a14cda 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -14,6 +14,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Bounty | `module/rewarded_task.py` | `implement` (main flow, `get_task_count`/`purchase_bounty_ticket`/per-area `rewarded_task_status` loop, not ported -- see Status), `to_bounty`/`to_choose_bounty` (nav), `get_los`/`one_detect`/`bounty_common_operation` (per-row SSS-color scan + sweep) | `ba_auto/tasks/bounty.py` | `to_bounty`'s bottom-nav "bus" icon -> this client's Work-hub `指名手配` card (`config.BOUNTY_CARD`), landing directly on Location Select with no separate bus-icon sub-navigation step; `get_los`/`one_detect`'s per-row `color.check_sweep_availability` SSS-color scan across however many rows are visible -> a fixed bottom-most row click (`config.BOUNTY_LATEST_STAGE_ROW_Y`), since this client's 3 areas each have exactly 10 stages (confirmed live, scrolling past the 10th is a no-op) all already SSS-cleared, making "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction; the reference's config-string per-area sweep-count list (`rewarded_task_times`, `get_task_count`) and its loop across all 3 areas -> a single date-ordinal-modulo rotation choosing ONE area per run, mirroring story_sweep.py/event_sweep.py's own rotation, per explicit user direction (2026-07-11); `purchase_bounty_ticket` (buying more tickets with real currency) -> not ported, matching plan.md's "OCR optional for coin balance/refresh logic, can skip for first version" | Done, live-tested for real -- see `plan.md` Phase 15. Live-calibrated against nik-gpu 2026-07-11 with **zero real tickets spent** during calibration (every ticket-usage confirm dialog reached was cancelled via Escape, verified by the ticket counter (6/6) unchanged before/after, across all 3 areas). All 3 areas (ハイウェイ/砂漠の線路/校舎, matching the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM groupings) confirmed to share an identical layout: same area-row/stage-row/modal-button coordinates, same 10-stage-per-area structure. The 任務情報 modal's MAX stepper, minus-button raised-color check, and ticket-usage confirm dialog are pixel-identical to event_sweep.py's own stage modal / shared `SWEEP_CONFIRM_*` dialog -- confirmed live -- and reused directly. The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`: that corner point reads dark on this screen regardless of modal state (different background art), so a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that does discriminate both states. **2 real sweeps confirmed live** (credits +180,000 then +36,000, clean automatic return home both times), which surfaced and fixed two real bugs: (1) `_set_sweep_count`'s count==1 path wrongly assumed the modal defaults to count=1 on open -- it actually remembers the last-used count -- causing an unintended 5-ticket spend instead of the intended 1; fixed via a new `_click_min_and_verify` that always forces a known baseline first. (2) `BOUNTY_SWEEP_RESULT_BUTTON_REGION`'s first-guess copy of event_sweep's own region overlapped `BOUNTY_SWEEP_START_BUTTON`'s real cyan pixels, causing `_find_result_button` to re-click it once tickets hit 0 -- which surfaced a real Pyroxene ticket-purchase prompt (no gem actually spent, confirmed by an unchanged balance, but a real near-miss). Fixed three ways: the region was corrected to the real pixel-scanned OK-button bbox; a new `min_pixels` parameter on `detector.find_color_centroid` (plus `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS`) filters out a second, smaller contamination source (stray cyan-range pixels in the modal's own reward-icon artwork); and `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` guard as a named `ends` condition. **Not yet re-confirmed live**: a fresh sweep with the fixes deployed (account at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow (only count=1 was ever tested; a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep instead of the single-OK dialog confirmed here). **Real hazard found and fixed via a real unattended `daily` cron run (2026-07-17)**: reported live by the user -- a real "Battle Complete" screen (a live ~3-minute combat timer) was found on the account instead of an instant sweep, with `_watch_sweep_result` having reported "swept" cleanly and no warnings anywhere in the log; every task that ran for the rest of that `daily` invocation failed to open its own screen. Manually reproducing the flow live (reaching the real confirm dialog and cancelling before confirming, the same safe-calibration pattern the original live-testing used) found the actual hazard: the 任務情報 modal has TWO separate action buttons stacked vertically -- the intended cyan 掃討開始 (start sweep, instant) and a separate gold 任務開始 (start mission, a REAL manual battle) directly below it, both showing an identical ticket-cost preview. Every check along the commit path (`_count_raised_above_one`/`_is_sweep_usage_confirm`/`_watch_sweep_result`'s own "swept" conditions) is a generic color/position probe with no verification of what's actually showing. Fixed with `_confirm_dialog_is_sweep`, an OCR text check (`config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT`, captured live from the real dialog: "指名手配チケットをN使用して、掃討をN回行いますか?") gating the one irreversible click in this flow -- cancels rather than confirms if the dialog doesn't read as a sweep. The exact mechanism that let a real run reach 任務開始 instead of 掃討開始 was not fully reproduced live (would mean deliberately repeating a real battle); the fix closes the hazard regardless of the exact upstream cause. Not yet re-verified live against a fresh real sweep. | | Login | `core/Baas_thread.py`, `module/restart.py` | `to_main_page` (generic post-launch arrival routine, reused by every other reference feature's own navigation -- no separate "login" module exists in the reference), `restart.py`'s `implement`/`start` (check app running, launch if not) | `ba_auto/tasks/login.py` | Reference detects every one-off popup (~20 named `img_reactions`/`rgb_possibles`) via `picture.co_detect` image-template matching; this port scopes down to what was actually confirmed live (title screen via a fixed-chrome logo-color probe, a real network-error notice, the daily attendance card, S.C.H.A.L.E NEWS) plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape but using this project's own established Enter-dismiss idiom rather than a blind coordinate click. `module/restart.py`'s kill-then-relaunch pattern ported directly via new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives (`pkill -f`/the account's own `/usr/local/bin/launch-blue-archive.sh`/`pgrep -f`/`xdotool search`) | Done. Live-calibrated 2026-07-16 on nik-gpu against the account's real overnight login-screen state (native 1920x1200 captures throughout, not the non-native `screenshots/daily_login/*.png` reference photos originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed live: the title screen's own logo reads a fixed brand-chrome color independent of rotating seasonal background art (confirmed across two different pieces), bright when clean and uniformly dimmed when a real notice is open on top of it (a genuine "ネットワークへの接続に失敗しました" network error surfaced unprompted during calibration); the daily attendance card only appears once per day (confirmed absent on an immediate same-day re-run after being claimed) and, like the network notice, responds to a plain Enter with no dedicated detection needed; the S.C.H.A.L.E NEWS popup needed a dedicated header-color probe since `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable on it (same class of mismatch as `gem_shop.py`'s own dialog -- confirmed live by direct pixel comparison). **A real stuck-loading incident hit live during calibration itself**: the loading transition (a full-bleed variant with no chrome, distinct from a brief chrome-visible variant also seen) stalled past 6 minutes with zero progress, confirmed not a network/process-health issue; the user's own live guidance ("kill the game and rerun it") identified the relaunch script, and a second attempt after manually killing+relaunching completed the entire remaining flow (title tap -> attendance card -> home) in well under 15 seconds, confirming the stall was a genuine stuck state now handled automatically by `_recover`. Also observed live (twice, non-deterministically) but deliberately NOT worked around: a known pre-existing client rendering bug (per the user) where the news popup's own promo image can get stuck as a blank white rectangle after closing -- confirmed harmless to this module specifically (sits clear of every probe point used here, and the shared home-check probes still read correctly through it), and the user's own fix for it (reload the app) is already this module's existing stuck-recovery path. Added as the very first step in `DEFAULT_ORDER`, ahead of mailbox, since no other task can reach home from the title/loading/attendance-card states on its own. **Not yet live-confirmed**: the infrequent 業務復帰ログインボーナス welcome-back login bonus card from the user's own reference screenshots (account wasn't in that state during calibration) -- expected to fall through to the same generic-Enter path already confirmed for the attendance card and network notice, but not yet exercised for real; and the kill+relaunch recovery path has only been exercised once, manually, not yet through a fresh invocation of the actual `login.py` module hitting a real stuck state on its own. **Connectivity-probe fix, `_connectivity_confirmed` (2026-07-17)**: reported live by the user via real pulled cron logs -- a 4:30 AM `daily` fire logged a clean `[login] reached home`, but every task that ran afterward (cafe, event_sweep, circle, lesson, arena, both shops, gem_shop, mailbox, stamina) failed to open its own screen, persisting across multiple consecutive `q4h` fires spanning hours. Per the user's own direct knowledge: the server's daily reset or a concurrent login from another device can silently kill the session while the client keeps showing a cached-looking, visually normal home screen -- the error only surfaces as the game's own "connection lost" popup once an actual navigation/API call is attempted, which `_true_home`'s purely-visual checks never triggered. Fixed per the user's own suggested design ("enter the stamina claim/reward area then go back home"): `_connectivity_confirmed` now opens the Mission panel and closes it again before `_wait_for_home` will return success. Reproducing this live (the user logged into the account on their phone to trigger a real session kill) surfaced two FURTHER false positives in the same family, both from an unusually long, multi-frame animated loading sequence that never resolved on its own: first with `navigation.is_on_subscreen` (single-pixel) as the panel-opened check, then again after upgrading to `navigation.is_header_bar_visible` (8-point) -- each independently got fooled by a different coincidental splash frame within the same run. Fixed by requiring `_true_home` to re-verify a second time, after a short wait, before finally trusting a passed connectivity check. **Confirmed live**: the stuck session was cleared with a manual kill+relaunch, then a genuinely fresh cold-start `login` run (game process not running at all) correctly launched the game, passed through the title screen, and reached independently-verified true home (full HUD -- Lv/AP/credits/gems/bottom nav all visible, confirmed via direct screenshot and a separate `_true_home`/`is_header_bar_visible` check, not just the module's own printed message). Not yet re-confirmed against a fresh instance of the original silent-session-death failure mode specifically (the live reproduction available this session was the full-kick-to-login-screen variant, not the "still looks like home but isn't" variant from the cron logs), though the same `_connectivity_confirmed` mechanism covers both by design. | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | +| Exit game | None -- searched `~/repo/baas-reference/` thoroughly, 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 toggle, not a game-client exit -- out of scope (this project's game and X display run on `nik-gpu`, shutting the host down would kill the SSH session too, nothing like what was requested). The only other app-closing call, `connection.py`'s `close_current_app` (line 381, plain ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts (`deal_with_package_incorrect`/`deal_with_func_call_timeout`) inside a persistent background scheduler that's designed to keep running indefinitely -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic motivated by this project's different architecture (cron launches a fresh one-shot process per preset, so closing the game after a run has real value the reference's always-on scheduler never needed), not a port of anything. | `ba_auto/tasks/exit_game.py` | No new driver/detector primitives needed -- reuses `navigation.return_to_home` (must-be-true-home gate), `navigation.is_modal_open` (verifies the Escape-triggered "exit the game?" dialog actually opened, the same generic dim-probe reused across bounty/gem_shop/circle's own confirm dialogs), `driver.keypress("Escape"/"Return")`, and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent) | Implemented, not yet live-tested. `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 this exact dialog -- every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, gated the same way (only fires from a verified true-home state). No force-kill fallback if the graceful Escape/Enter path doesn't verify -- matches this project's established "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty), rather than reaching for `driver.kill_game()`. Opt-in only, appended to the end of the `daily`/`q4h` presets (not `DEFAULT_ORDER`), since it deliberately ends the session. Needs a real live check before trusting it in unattended cron: `is_modal_open`'s generic dim-probe hasn't been confirmed specifically against this dialog's own visual layout yet. | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | diff --git a/ba_auto/tasks/exit_game.py b/ba_auto/tasks/exit_game.py new file mode 100644 index 0000000..7e6bb81 --- /dev/null +++ b/ba_auto/tasks/exit_game.py @@ -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.") diff --git a/ba_daily.py b/ba_daily.py index 6507321..6004e75 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -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") diff --git a/plan.md b/plan.md index aeae7f6..1c55671 100644 --- a/plan.md +++ b/plan.md @@ -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