"""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")