Implement login flow from title screen to home screen

- Added `login.py` to handle the login process, including detection of various states such as the title screen, network error notices, loading transitions, daily attendance cards, and the home screen.
- Integrated a recovery mechanism to relaunch the game if it gets stuck during loading.
- Updated `ba_daily.py` to include the login task in the default execution order, ensuring it runs first to navigate to the home screen before other tasks.
- Fixed a bug in the home detection logic to accurately identify when the user is on the true home screen.
- Live-tested and calibrated the login flow against real game states, confirming functionality and addressing any issues encountered during testing.
This commit is contained in:
Nik Afiq 2026-07-16 11:07:43 +09:00
parent 88cb1a933a
commit a9f0bbe42f
7 changed files with 413 additions and 4 deletions

View File

@ -1159,3 +1159,92 @@ SOCIAL_ICON = (812, 1080)
# サークル card, leftmost of three (サークル/フレンド/助っ人) on the # サークル card, leftmost of three (サークル/フレンド/助っ人) on the
# ソーシャル hub page. # ソーシャル hub page.
CIRCLE_CARD = (463, 613) 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

View File

@ -127,3 +127,42 @@ def colors_at(points):
def wait(seconds): def wait(seconds):
time.sleep(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())

View File

@ -118,7 +118,21 @@ def return_to_home(driver):
Returns True once home is confirmed reached, False if still not home Returns True once home is confirmed reached, False if still not home
after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort, after RETURN_HOME_MAX_ROUNDS -- callers should treat False as "abort,
don't guess further" rather than assume home was reached. 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 escalated = False
for round_num in range(1, RETURN_HOME_MAX_ROUNDS + 1): for round_num in range(1, RETURN_HOME_MAX_ROUNDS + 1):
if not _not_home(driver): if not _not_home(driver):
@ -161,7 +175,7 @@ def click_back(driver, verify_fn, max_attempts=3):
unconfirmed after max_attempts. unconfirmed after max_attempts.
""" """
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):
if attempt == max_attempts: if attempt == max_attempts and driver.window_exists():
driver.focus_game() driver.focus_game()
driver.click(*BACK_BUTTON) driver.click(*BACK_BUTTON)
driver.wait(1.5) driver.wait(1.5)

File diff suppressed because one or more lines are too long

234
ba_auto/tasks/login.py Normal file
View File

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

View File

@ -3,9 +3,10 @@
import sys import sys
from ba_auto import config, driver, navigation 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 = { TASKS = {
"login": login.run,
"mailbox": mailbox.run, "mailbox": mailbox.run,
"cafe": cafe.run, "cafe": cafe.run,
"stamina": stamina.run, "stamina": stamina.run,
@ -28,8 +29,15 @@ TASKS = {
# module docstring. gem_shop and circle are the opposite case -- like # module docstring. gem_shop and circle are the opposite case -- like
# mailbox/cafe/stamina, they only ever reclaim a genuinely free (0 yen), # 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 # 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. # to select), so they belong in the default flow rather than opt-in. login
DEFAULT_ORDER = ["mailbox", "cafe", "stamina", "gem_shop", "circle"] # 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 # How many times _ensure_home retries navigation.return_to_home as a whole
# (not to be confused with that function's own internal # (not to be confused with that function's own internal

24
plan.md
View File

@ -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. **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 ## Prerequisites
### OCR ### OCR