diff --git a/ba_auto/config.py b/ba_auto/config.py index da4cd3b..c7e9c27 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -1159,3 +1159,92 @@ SOCIAL_ICON = (812, 1080) # サークル card, leftmost of three (サークル/フレンド/助っ人) on the # ソーシャル hub page. CIRCLE_CARD = (463, 613) + +# Login flow: the "TOUCH TO START" title screen through whatever one-off +# daily popups appear (a real network-hiccup notice, the daily attendance +# card, an infrequent welcome-back login bonus, S.C.H.A.L.E NEWS) to the +# true home screen. Ports the reference's core/Baas_thread.py::to_main_page +# (its own generic post-launch arrival routine -- co_detect reacting to +# ~20 named one-off img_reactions/rgb_possibles until the 'main_page' rgb +# state is reached) plus module/restart.py's kill-and-relaunch-if-stuck +# pattern. See ba_auto/tasks/login.py's module docstring for the full +# live-calibration writeup, including a real stuck-loading incident hit +# during calibration itself. +# +# All coordinates/colors below are pixel-scanned from real scrot captures +# on nik-gpu at the native 1920x1200, not the non-native-resolution +# screenshots/daily_login/*.png reference photos the user originally +# supplied (same "not 1:1 with real game coordinates" finding already +# documented for screenshots/gem_shop/ and screenshots/cafe/student/). +GAME_PROCESS_NAME = "BlueArchive.exe" +GAME_LAUNCH_SCRIPT = "/usr/local/bin/launch-blue-archive.sh" + +LOGIN_TOUCH_TO_START = (960, 1060) +# S.C.H.A.L.E NEWS popup's own X close button. +LOGIN_NEWS_CLOSE_BUTTON = (1710, 215) + +# The ブルーアーカイブ logo (top-left) is fixed UI chrome, independent of +# the title screen's own rotating seasonal background art -- confirmed +# live across two completely different background pieces (a beach BBQ +# scene and a train-interior scene) reading the exact same RGB at every +# probe point. It reads one of three ways: +# bright cyan (0, 215, 250) -- clean title screen, tap to proceed +# dimmed cyan (0, 97, 114) -- a notice/dialog open on top of the title +# screen (live-confirmed: a real "network +# connection failed" error), Enter dismisses +# neither -- title screen has been left entirely +# (loading, the attendance card, home, etc.) +# Multi-point (not single-pixel), matching this project's established +# multi-point-beats-single-point technique (navigation.is_header_bar_visible, +# gem_shop's GEM_SHOP_DIALOG_PROBES) -- confirmed against every other +# captured state (loading screen, attendance card, true home) to avoid +# false-matching a background art color that coincidentally lands in range +# at any single one of these points. +LOGIN_LOGO_PROBES = ((100, 120), (300, 120), (320, 100)) +LOGIN_LOGO_BRIGHT_RGB = (0, 215, 250) +LOGIN_LOGO_DIMMED_RGB = (0, 97, 114) +LOGIN_LOGO_COLOR_TOLERANCE = 20 + +# S.C.H.A.L.E NEWS popup's own header bar -- a solid, distinctive blue +# spanning its full width. navigation.is_modal_open/is_on_subscreen both +# proved unreliable here (same class of default-probe mismatch as +# gem_shop.py's own dialog, see GEM_SHOP_DIALOG_PROBES): the dialog +# overlays home directly, and is_modal_open's single probe point happens to +# land on the dialog's own bright header/body rather than a dimmed +# backdrop, so it reads not-dark (i.e. "no modal") whether the dialog is +# open or not -- confirmed by direct pixel comparison against the closed +# state at the same points. +LOGIN_NEWS_HEADER_PROBES = ((500, 215), (700, 215), (900, 215), (1100, 215), (1300, 215)) +LOGIN_NEWS_HEADER_RGB = (30, 155, 248) +LOGIN_NEWS_HEADER_TOLERANCE = 35 + +# Positive confirmation that the bottom nav bar (カフェ/スケジュール/...) +# is showing: a flat, near-white, tightly-clustered background between the +# icons. Needed because navigation.is_on_subscreen/is_modal_open are BOTH +# calibrated only to distinguish in-game states from each other -- neither +# was ever designed to rule out the pre-login title screen or the daily +# attendance card, and a real live test (2026-07-16) found both of those +# states also read "not a subscreen, no modal open", the same false/false +# pattern as true home, causing login.py's very first home-check to return +# a false positive while still sitting on the title screen (a second run +# hit the same false positive while still on the unclaimed attendance +# card, which would have silently skipped that day's reward). This +# multi-point check (min channel + max spread, not a single RGB target) +# 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 + +LOGIN_POLL_INTERVAL = 2 +# Generous per-attempt budget: a normal run (title tap -> loading -> +# attendance card -> home) completed in well under 15s during live +# calibration, but a real stuck-loading incident during that same session +# ran past 6 minutes with zero progress before a manual kill+relaunch was +# needed -- wide margin above the normal case, still well under the +# reference's own 600s co_detect default timeout. +LOGIN_TIMEOUT_SECONDS = 240 +LOGIN_MAX_RELAUNCHES = 2 +LOGIN_KILL_WAIT_ATTEMPTS = 10 # ~20s for the old process to fully exit +LOGIN_RELAUNCH_WAIT_ATTEMPTS = 45 # ~90s+ for the window to reappear diff --git a/ba_auto/driver.py b/ba_auto/driver.py index 7707b7c..6faf075 100644 --- a/ba_auto/driver.py +++ b/ba_auto/driver.py @@ -127,3 +127,42 @@ def colors_at(points): def wait(seconds): time.sleep(seconds) + + +def kill_game(): + # check=False -- pkill exits 1 with no output when nothing matches, + # which is a normal outcome here (e.g. the process already died on its + # own), not an error worth raising on. + run_command(["pkill", "-f", config.GAME_PROCESS_NAME], check=False) + + +def launch_game(): + # The launch script (see config.GAME_LAUNCH_SCRIPT) blocks until the + # game process exits, by design (it's meant to be run as a foreground + # session launcher) -- Popen + start_new_session=True detaches it so + # this process can keep polling for the window instead of hanging for + # the whole play session. + subprocess.Popen( + [config.GAME_LAUNCH_SCRIPT], + env=config.ENV, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + +def is_game_running(): + result = run_command( + ["pgrep", "-f", config.GAME_PROCESS_NAME], + check=False, capture_output=True, text=True, + ) + return bool(result.stdout.strip()) + + +def window_exists(): + result = run_command( + ["xdotool", "search", "--name", config.WINDOW_NAME], + check=False, capture_output=True, text=True, + ) + return bool(result.stdout.strip()) diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index c5f2b7b..3b501c3 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -118,7 +118,21 @@ def return_to_home(driver): Returns True once home is confirmed reached, False if still not home after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort, don't guess further" rather than assume home was reached. + + Bails out immediately (False) if the game window doesn't exist at all, + rather than pressing/escalating into a guaranteed crash: the escalation + step below calls driver.focus_game(), which raises RuntimeError if it + can't find the window -- a real crash hit live (2026-07-16) when + ba_daily.py's centralized pre-task _ensure_home() called this function + before login.py (the one task able to launch the game itself, see its + own module docstring) ever got a chance to run. Every OTHER task still + crashes as before via its own driver.focus_game() call once dispatched + -- correct for them, since they have no ability to start the game -- + this only changes behavior for the specific "no window yet" case this + function itself can't do anything about anyway. """ + if not driver.window_exists(): + return False escalated = False for round_num in range(1, RETURN_HOME_MAX_ROUNDS + 1): if not _not_home(driver): @@ -161,7 +175,7 @@ def click_back(driver, verify_fn, max_attempts=3): unconfirmed after max_attempts. """ for attempt in range(1, max_attempts + 1): - if attempt == max_attempts: + if attempt == max_attempts and driver.window_exists(): driver.focus_game() driver.click(*BACK_BUTTON) driver.wait(1.5) diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 4458df0..f6ff40f 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -12,6 +12,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | | 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). | +| 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. | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | 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). | | 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 | diff --git a/ba_auto/tasks/login.py b/ba_auto/tasks/login.py new file mode 100644 index 0000000..dc5ce95 --- /dev/null +++ b/ba_auto/tasks/login.py @@ -0,0 +1,234 @@ +"""Login: title screen through daily popups to the true home screen. + +Reference flow: `module/restart.py`'s `implement`/`start` (check the app is +running, launch it if not) hands off into `core/Baas_thread.py`'s +`to_main_page` -- the reference's own generic post-launch arrival routine. +`to_main_page` is a `picture.co_detect` call wired with ~20 named +img_reactions/rgb_possibles (download notices, the daily attendance card, +a login-feature/login-store banner, news, rank-up cutscenes, etc.): +whatever recognized state appears gets clicked through, repeatedly, until +the 'main_page' rgb state is reached. There's no separate "login" module in +the reference -- it's baked into that shared arrival routine, reused by +every other feature's own navigation too. + +This project doesn't have image-template assets for that whole catalog, so +rather than one-off hand-calibrating every possible reference dialog, this +port scopes down to what was actually confirmed live on nik-gpu (see below) +plus a bounded generic "press Enter" fallback for anything else recognized +neither as the title screen nor the news popup -- Enter is this project's +own established safe dismiss/advance action for exactly this class of +one-off notice/card (see gem_shop.py's `_claim_free_package`, circle.py's +`_dismiss_reward`), and this flow never reaches a screen where Enter is +dangerous (that hazard, per CLAUDE.md, is specific to in-battle/spend +confirmations arena.py/story_sweep.py guard against, not the pre-login +popup chain). This mirrors the shape of the reference's own co_detect +fallback (a blind action once nothing recognized matches for a while) +while staying closer to this project's own established idiom than +reference's blind coordinate tentative_click. + +Live-calibrated 2026-07-16 on nik-gpu by driving the real flow end-to-end +from the account's actual overnight login-screen state (native 1920x1200 +captures throughout, saved to scratchpad/login_probe_*.png during the +session -- NOT the non-native screenshots/daily_login/*.png the user +originally supplied, same "not 1:1 with real coordinates" gap already +documented for screenshots/gem_shop/ and screenshots/cafe/student/). + +Confirmed states, in the order they were actually hit: + +1. Title screen ("TOUCH TO START") -- `_logo_state` reads "bright" via the + ブルーアーカイブ logo's fixed brand-chrome color (see config.py's + `LOGIN_LOGO_*`), confirmed identical across two completely different + seasonal background arts. Click `LOGIN_TOUCH_TO_START`. +2. A real "ネットワークへの接続に失敗しました" (network connection failed) + notice, live-hit unprompted during calibration -- `_logo_state` reads + "dimmed" (same logo, uniformly darkened by the notice's own overlay). + Enter dismisses it, dropping back to the plain title screen (state 1 + again). +3. A loading transition. Two visually distinct variants were both hit + live: a brief one that keeps the logo/UID chrome visible (resolves in a + few seconds), and a full-bleed one that replaces the ENTIRE screen with + rotating splash art and a center spinner, no chrome at all. The first + real run's loading genuinely got stuck in the second variant for over 6 + minutes with zero progress (confirmed not a network issue -- ping and + the game process were both healthy) -- the user's own live guidance was + "kill the game and rerun it", identifying `/usr/local/bin/ + launch-blue-archive.sh` as the relaunch entry point (see `_recover` and + config.py's `GAME_PROCESS_NAME`/`GAME_LAUNCH_SCRIPT`). This directly + parallels the reference's own `restart.py` kill+relaunch pattern, not + an invented workaround. A second attempt after relaunching completed the + entire remaining flow in well under 15 seconds, confirming the first + attempt's multi-minute stall was a genuine stuck state, not normal + loading variance. +4. アロナの毎日出席簿 (Arona's daily attendance card, a 10-day stamp + calendar) -- only appears if not yet claimed today (confirmed: absent + entirely on a same-day second run after the first run's Enter already + claimed it). No distinct visual detection was built for this specific + card -- it falls through to the generic Enter fallback like every other + unrecognized pre-home state, confirmed live to correctly claim/dismiss + it in one press. +5. True home, usually with the S.C.H.A.L.E NEWS popup open on top -- + `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable for + detecting this dialog specifically (confirmed live via direct pixel + comparison: it overlays home directly, and is_modal_open's probe point + lands on the dialog's own bright header/body rather than a dimmed + backdrop, reading "no modal" whether it's open or not -- same class of + mismatch already documented for gem_shop.py's own dialog). Fixed with a + dedicated `_news_dialog_open` multi-point header-color check; closed via + its own X button (`LOGIN_NEWS_CLOSE_BUTTON`), not Enter -- not + confirmed to be Enter-bound, unlike every other popup in this flow. + `_true_home` combines this with the existing shared + is_on_subscreen/is_modal_open so the terminal check is correct whether + or not the news popup happens to show that day. + +One additional real client bug surfaced live but is explicitly NOT ported +around: a known intermittent rendering bug (per the user, pre-existing and +unrelated to this project) where the news popup's own promotional image can +get stuck on-screen as a blank white rectangle after the dialog is +otherwise closed. Confirmed live twice in the same session -- present once, +absent on an immediately-following clean run with identical navigation, so +not deterministic. Confirmed harmless to this task specifically: pixel- +checked live that the stuck rectangle sits well clear of every probe point +this module and navigation.py use (`LOGIN_LOGO_PROBES`, +`LOGIN_NEWS_HEADER_PROBES`, `navigation.SUBSCREEN_HEADER_PROBE`, +`navigation.MODAL_DIM_PROBE`), and `navigation.is_on_subscreen`/ +`is_modal_open` both still correctly read true-home with the artifact +on-screen. The user's own fix (reload the app) is already this module's +existing stuck-recovery path, so no separate handling was added. + +A real correctness bug was found and fixed the same session, via the actual +module's own first live run (not manual clicking): `_true_home`'s original +definition (not-subscreen AND not-modal AND no-news-dialog) is a set of +negative conditions never actually calibrated against the pre-login states +this module itself introduces -- both the plain title screen AND the +unclaimed daily attendance card independently satisfy all three of them +(neither looks like a subscreen or an open modal to those checks, which +were built assuming the game world is always already reached). The first +real `login` run false-positived "reached home" while still sitting on the +title screen, before ever clicking anything. Fixed by adding +`_home_nav_bar_visible`, a positive check for the bottom nav bar's own +flat, near-white background band -- confirmed live to hold uniquely on +true home and fail on every other captured state, including home with the +news dialog still open (which dims that same band). Re-run afterward: a +real cold start (game process not running at all) correctly launched the +game, clicked through the title screen, and reached the genuinely-confirmed +home screen. + +Not yet live-confirmed: the ~infrequent 業務復帰ログインボーナス +(welcome-back login bonus, a "long trip" returning-player reward, +`screenshots/daily_login/3_claim2(...).png`) card the user's own reference +screenshots showed -- did not appear during calibration (this account was +not in that state). Expected to fall through to the same generic Enter +fallback as the daily attendance card, matching the same "any one-off +pre-home card responds to Enter" pattern already confirmed for state 4 +above and the network notice in state 2, but not yet exercised for real. + +Added to `ba_daily.py`'s `DEFAULT_ORDER` as the very first step, ahead of +mailbox -- every other task's own navigation assumes the home screen is +already reachable via `navigation.return_to_home`'s ordinary +Escape/BACK_BUTTON press loop, which has no way to get there from the +title/loading/attendance-card states this module handles. `_wait_for_home` +checks true-home FIRST on every iteration (same contract as +`navigation.wait_for_state`), so a session that's already logged in and +sitting at home is a fast no-op, not a risky blind click. +""" +import time + +from ba_auto import navigation + + +def _color_matches(rgb, target, tolerance): + return all(abs(c - t) <= tolerance for c, t in zip(rgb, target)) + + +def _logo_state(driver, config): + colors = driver.colors_at(config.LOGIN_LOGO_PROBES) + if all(_color_matches(c, config.LOGIN_LOGO_BRIGHT_RGB, config.LOGIN_LOGO_COLOR_TOLERANCE) for c in colors): + return "bright" + if all(_color_matches(c, config.LOGIN_LOGO_DIMMED_RGB, config.LOGIN_LOGO_COLOR_TOLERANCE) for c in colors): + return "dimmed" + return "absent" + + +def _news_dialog_open(driver, config): + colors = driver.colors_at(config.LOGIN_NEWS_HEADER_PROBES) + return all(_color_matches(c, config.LOGIN_NEWS_HEADER_RGB, config.LOGIN_NEWS_HEADER_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) + and not navigation.is_on_subscreen(driver) + and not navigation.is_modal_open(driver) + and not _news_dialog_open(driver, config) + ) + + +def _wait_for_home(driver, config): + start = time.time() + while time.time() - start < config.LOGIN_TIMEOUT_SECONDS: + if _true_home(driver, config): + return True + if _news_dialog_open(driver, config): + driver.click(*config.LOGIN_NEWS_CLOSE_BUTTON) + elif _logo_state(driver, config) == "bright": + driver.click(*config.LOGIN_TOUCH_TO_START) + else: + # Dimmed title-screen notice, a loading transition, the daily + # attendance card, an infrequent login-bonus card, or any other + # one-off dialog -- see module docstring for why a blind Enter + # is this flow's established safe generic action here. + driver.keypress("Return") + driver.wait(config.LOGIN_POLL_INTERVAL) + return False + + +def _wait_for_window(driver, config): + for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS): + if driver.window_exists(): + driver.wait(3) + return True + driver.wait(2) + return False + + +def _recover(driver, config): + print("[login] not home after the timeout -- killing and relaunching the game") + driver.kill_game() + for _ in range(config.LOGIN_KILL_WAIT_ATTEMPTS): + if not driver.is_game_running(): + break + driver.wait(2) + driver.launch_game() + if not _wait_for_window(driver, config): + print("[login] warning: game window did not reappear after relaunch") + return False + driver.focus_game() + return True + + +def run(driver, config): + if not driver.window_exists(): + print("[login] game window not found -- launching") + driver.launch_game() + if not _wait_for_window(driver, config): + print("[login] warning: game window never appeared after launch -- giving up") + return + driver.focus_game() + + for attempt in range(1, config.LOGIN_MAX_RELAUNCHES + 1): + if _wait_for_home(driver, config): + print("[login] reached home") + return + if attempt < config.LOGIN_MAX_RELAUNCHES: + _recover(driver, config) + + print(f"[login] warning: could not reach home after {config.LOGIN_MAX_RELAUNCHES} attempts -- manual check needed") diff --git a/ba_daily.py b/ba_daily.py index 8303ce1..f9cb8a6 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -3,9 +3,10 @@ import sys from ba_auto import config, driver, navigation -from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, gem_shop, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep +from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep TASKS = { + "login": login.run, "mailbox": mailbox.run, "cafe": cafe.run, "stamina": stamina.run, @@ -28,8 +29,15 @@ TASKS = { # module docstring. gem_shop and circle are the opposite case -- like # mailbox/cafe/stamina, they only ever reclaim a genuinely free (0 yen), # once-per-day resource with no choice to make (claim it or don't, nothing -# to select), so they belong in the default flow rather than opt-in. -DEFAULT_ORDER = ["mailbox", "cafe", "stamina", "gem_shop", "circle"] +# to select), so they belong in the default flow rather than opt-in. login +# is first, ahead of everything else: every other task's own navigation +# assumes the home screen is already reachable via the ordinary +# Escape/BACK_BUTTON press loop in navigation.return_to_home, which has no +# way to get there from the title/loading/attendance-card states login.py +# handles -- see that module's docstring. login.run() checks true-home +# first on every internal poll, so this is a fast no-op on a session that's +# already logged in, not a risky blind click every single day. +DEFAULT_ORDER = ["login", "mailbox", "cafe", "stamina", "gem_shop", "circle"] # How many times _ensure_home retries navigation.return_to_home as a whole # (not to be confused with that function's own internal diff --git a/plan.md b/plan.md index 7c39403..7dcdd73 100644 --- a/plan.md +++ b/plan.md @@ -768,6 +768,30 @@ This is a recurrence of a gotcha first found during `event_sweep.py`'s original **Follow-up, same session — "could the same error happen in other scripts?"** The two-layer fix above only covers `navigation.return_to_home`/`driver.focus_game()`. Auditing every direct use of the shared `(85,55)` coordinate found 5 places that clicked it *without* going through either: `shop_common.py`/`shop_tactical.py`'s end-of-run cleanup clicks, and — the real risk — `lesson.py`'s `_ensure_location_select_list` (had its own retry loop, but no overlay escalation) and `_close_region_grid` (called up to 12x per run, zero retry or verification at all; a silently-missed click here leaves the next region's `_open_region_grid` starting from the wrong screen and silently skipping that region). Fixed with a new shared `navigation.click_back(driver, verify_fn, max_attempts=3)` — the same retry + windowraise-escalation pattern as `return_to_home`, but parameterized by a caller-supplied verification check instead of hardcoding "reached home," for callers (like `lesson.py`'s per-region-map → list transition) that only want to go back ONE level. Applied to both `lesson.py` call sites; the three lower-risk end-of-run cleanup clicks (`lesson.py`, `shop_common.py`, `shop_tactical.py`) were switched to call `navigation.return_to_home` directly instead, since "go all the way home" is exactly their intent anyway. `config.SHOP_BACK_BUTTON`/`LESSON_BACK_BUTTON` were then removed as dead code (redundant with `navigation.BACK_BUTTON`, matching `EVENT_BACK_BUTTON`'s earlier removal for the identical reason). **Confirmed live**: `lesson` (0 tickets, so the no-op path — confirmed clean return home, no warnings), `shop_common` (a real purchase, 8 items/1,211,500 credits — confirmed clean return home), and `shop_tactical` (a real purchase, 2 items/45 tactical coin — confirmed clean return home) all ran successfully through the real CLI with the new navigation. `lesson.py`'s two hardened mid-run call sites (`_close_region_grid`/`_ensure_location_select_list`) weren't actually exercised by this test, though, since 0 tickets meant the scan/execute phase never ran — they'll get real coverage the next time `lesson` runs with tickets available. +### Phase 18: Login (title screen -> home) (2026-07-16) + +Reference: `core/Baas_thread.py`'s `to_main_page` (the generic post-launch arrival routine every other reference feature's own navigation reuses -- there's no separate "login" module in the reference) plus `module/restart.py`'s `implement`/`start` (check the app is running, launch it if not). Local: `ba_auto/tasks/login.py`. Requested directly by the user, with the game genuinely sitting on the real login screen at request time: "go through login page to go to home." + +Reference flow: `to_main_page` is a `picture.co_detect` call wired with ~20 named `img_reactions`/`rgb_possibles` (download notices, the daily attendance card, a login-feature/login-store banner, news, rank-up cutscenes, etc.) -- whatever recognized state appears gets clicked through, repeatedly, until the `main_page` rgb state is reached. This project has no image-template assets for that whole catalog, so rather than hand-calibrating every possible reference dialog, the port scopes down to what was actually confirmed live plus a bounded generic Enter-press fallback for anything else recognized, mirroring co_detect's own "blind action once nothing matches" fallback shape using this project's own established Enter-dismiss idiom. + +**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` the user originally supplied -- same not-1:1 gap already documented for `screenshots/gem_shop/`/`screenshots/cafe/student/`). Confirmed states, in the order actually hit: + +1. Title screen ("TOUCH TO START") -- the ブルーアーカイブ logo (top-left) is fixed brand chrome independent of the rotating seasonal background art, confirmed identical across two completely different background pieces (a beach BBQ scene, a train interior). Click `LOGIN_TOUCH_TO_START`. +2. A real "ネットワークへの接続に失敗しました" (network connection failed) notice, hit unprompted during calibration -- same logo, uniformly dimmed by the notice's own overlay. Enter dismisses it, dropping back to state 1. +3. A loading transition, two visually distinct variants: a brief chrome-visible one, and a full-bleed one (rotating splash art, center spinner, no chrome at all) that **genuinely got stuck for over 6 minutes with zero progress** during the first live attempt -- confirmed not a network/process-health issue (ping and the game process were both healthy throughout). The user's own live guidance mid-session ("Usually I would kill the game and rerun it to fix it", identifying `/usr/local/bin/launch-blue-archive.sh` and the game's working directory) matched the reference's own `restart.py` kill+relaunch pattern exactly. A manual kill+relaunch recovered immediately; a second attempt completed the entire remaining flow in well under 15 seconds, confirming the stall was a genuine stuck state, not normal variance. Ported as `login.py`'s own `_recover`, using new `driver.kill_game`/`launch_game`/`is_game_running`/`window_exists` primitives. +4. アロナの毎日出席簿 (the daily attendance card) -- only appears once per day (confirmed absent on an immediate same-day re-run after the first run's Enter already claimed it). Falls through to the generic Enter fallback like every other unrecognized pre-home state, confirmed live to correctly claim/dismiss it in one press. +5. True home, usually with the S.C.H.A.L.E NEWS popup open on top -- `navigation.is_on_subscreen`/`is_modal_open` both proved unreliable for this dialog specifically (same class of mismatch as `gem_shop.py`'s own dialog: it overlays home directly, and `is_modal_open`'s probe point lands on the dialog's own bright header/body rather than a dimmed backdrop). Fixed with a dedicated `_news_dialog_open` multi-point header-color check; closed via its own X button, not Enter (not confirmed to be Enter-bound, unlike everything else in this flow). + +**A known pre-existing client bug was observed live but deliberately not worked around**: per the user, a rendering bug (unrelated to this project) where the news popup's own promotional image can get stuck on-screen as a blank white rectangle after the dialog is otherwise closed. Confirmed live twice in the same session -- present once, absent on an immediately-following clean run with identical navigation, so not deterministic. Confirmed harmless to this task specifically: the stuck rectangle sits well clear of every probe point this module and `navigation.py` use, and the true-home checks still read correctly through it. The user's own fix (reload the app) is already this module's existing stuck-recovery path, so no separate handling was added. + +**A real correctness bug was found and fixed via the actual module's own first live run** (not manual clicking): the original `_true_home` (not-subscreen AND not-modal AND no-news-dialog) is a set of negative conditions never actually calibrated against the pre-login states this module itself introduces -- both the plain title screen AND the unclaimed daily attendance card independently satisfy all three (neither looks like a subscreen or an open modal to checks that were built assuming the game world is always already reached). The first real `login` run false-positived "reached home" while still sitting on the title screen, before clicking anything. Fixed by adding `_home_nav_bar_visible`, a positive multi-point check for the bottom nav bar's own flat, near-white background band -- confirmed live to hold uniquely on true home and fail on every other captured state, including home with the news dialog still open (which dims that same band). Re-run afterward: a real cold start (game process not running at all -- confirmed via `pkill`) correctly launched the game via `driver.launch_game()`, clicked through the title screen, and reached the genuinely-confirmed home screen. + +**A second real bug was found via the same cold-start test, one layer up**: `ba_daily.py`'s centralized pre-task `_ensure_home()` calls `navigation.return_to_home`, which calls `driver.focus_game()` partway through its escalation budget -- and `focus_game()` hard-raises `RuntimeError` if the game window doesn't exist at all, crashing the whole process *before* `login.run()` (the one task able to launch the game itself) ever got a chance to run, making its own launch-if-missing logic unreachable dead code. Fixed by having `navigation.return_to_home` bail out immediately (`False`) if `driver.window_exists()` is false, rather than pressing/escalating into a guaranteed crash, plus the same guard on `navigation.click_back`'s own escalation. Every other task is unaffected -- they still crash exactly as before once dispatched (correct for them, since none of them can launch the game themselves). + +Added as the very first step in `ba_daily.py`'s `DEFAULT_ORDER`, ahead of mailbox: every other task's own navigation assumes the home screen is already reachable via `navigation.return_to_home`'s ordinary Escape/BACK_BUTTON press loop, which has no way to get there from the title/loading/attendance-card states this module handles. `_wait_for_home` checks true-home first on every poll, so a session that's already logged in and sitting at home is a fast no-op, not a risky blind click every day. + +**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 automatic kill+relaunch recovery path (`_recover`) has only been exercised once, manually, not yet triggered by `login.py`'s own `LOGIN_TIMEOUT_SECONDS` budget hitting a real stuck state on its own. + ## Prerequisites ### OCR