- Implemented `_connectivity_confirmed` to verify session validity by opening the Mission panel, addressing two failure modes: 1. Bright full-screen transition frames that could falsely satisfy home checks. 2. Dead sessions due to server resets or concurrent logins, which appear as normal home screens until navigation is attempted. - Improved detection logic by requiring a second verification of `_true_home` after a short wait, reducing the likelihood of coincidental false positives. - Confirmed live against real scenarios, ensuring robust handling of session states during login.
387 lines
21 KiB
Python
387 lines
21 KiB
Python
"""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.
|
|
|
|
A second real correctness bug was found via real unattended cron runs the
|
|
following day (2026-07-17), reported live by the user together with the
|
|
actual pulled cron logs (`~/ba_logs/daily.log`/`q4h.log`): the 4:30 AM
|
|
`daily` fire logged `[login] reached home` cleanly, but then every single
|
|
task that ran afterward failed to open its own screen -- garbled OCR reads,
|
|
"screen/panel not detected after click" repeated across cafe, event_sweep,
|
|
circle, lesson, arena, both shops, gem_shop, mailbox, and stamina -- and
|
|
this persisted across multiple consecutive `q4h` fires spanning hours (the
|
|
account only recovered around the 17:00 fire the following day). Per the
|
|
user's own direct knowledge of this game's real behavior: the server's own
|
|
daily reset (~4 AM JST) or a concurrent login from another device (e.g. the
|
|
phone) can silently kill the session while the client keeps showing the
|
|
last-known-good home screen with no visible error -- the error only
|
|
surfaces once an actual navigation/API call is attempted, as the game's own
|
|
"connection lost" popup, which no individual task's own screen-detection
|
|
logic was built to recognize. `_true_home`'s checks are all purely visual
|
|
(nav bar shape, subscreen/modal darkness, news-dialog color), so a dead
|
|
session that still *looks* like home sailed straight through undetected.
|
|
|
|
Separately, reproducing this live (the user logged into the account on
|
|
their phone specifically to trigger it) surfaced a THIRD, distinct false
|
|
positive in the same family: a real run printed "reached home" while the
|
|
game was still showing a bright, fully non-interactive loading/transition
|
|
frame (tower splash art, zero HUD) -- a transient frame apparently bright
|
|
and flat enough to coincidentally satisfy `_home_nav_bar_visible` for one
|
|
screenshot, moments before the actual home screen rendered.
|
|
|
|
Both gaps share one fix, per the user's own suggested design ("change the
|
|
login to enter the stamina claim/reward area then go back home"):
|
|
`_connectivity_confirmed` now opens the Mission panel (config.MISSION_ICON,
|
|
the same icon stamina.py uses) and closes it again, right after `_true_home`
|
|
passes, before `_wait_for_home` will return success. A transition frame's
|
|
false positive gets caught because the click lands on non-interactive
|
|
background art and the panel never opens; a truly dead session gets caught
|
|
because the game's own connection-error dialog appears instead of the
|
|
Mission panel -- either way, `_wait_for_home` just treats it as "not home
|
|
yet" and retries the whole cycle, which will naturally fall through to the
|
|
existing generic Enter-fallback if an actual dialog needs dismissing, and
|
|
escalate to `_recover`'s kill+relaunch if the whole thing never resolves
|
|
within the timeout. **Confirmed live**: reproduced the real stuck-login
|
|
state from the phone-login trigger, ran the actual `login` task against it,
|
|
and caught it printing a false "reached home" while genuinely still on a
|
|
loading transition frame (verified via a follow-up screenshot showing no
|
|
HUD at all) -- this is the exact live evidence that led to
|
|
`_connectivity_confirmed`.
|
|
|
|
That first version of `_connectivity_confirmed` used `navigation.
|
|
is_on_subscreen` (a single-pixel check) to verify the Mission panel opened
|
|
-- re-run live against the SAME still-stuck session and it ALSO
|
|
false-positived: a different coincidental frame in the extended animated
|
|
loading sequence satisfied the single subscreen-header pixel right after
|
|
the click, on the very same run whose `_true_home` check had already been
|
|
fooled by yet another frame. Switched to `navigation.is_header_bar_visible`
|
|
(8 spread-out points, all must read bright) -- the same "harder to fool by
|
|
one coincidental bright pixel" upgrade this project already made for
|
|
cafe.py's rank-up detection, reused here for the identical reason. Re-run
|
|
live a third time against the same still-stuck session: STILL a false
|
|
positive, confirmed by an immediate independent follow-up check (screenshot
|
|
plus a direct re-run of `_true_home`/`is_header_bar_visible`/
|
|
`_connectivity_confirmed`, all reading false) moments later, on a THIRD
|
|
different splash frame (two characters on a tank, a `巡回中` sign). This
|
|
extended loading sequence turned out to genuinely never resolve on its own
|
|
across three separate `login` invocations and several real minutes of
|
|
elapsed time (matching the user's own "if this doesn't solve in 3-5
|
|
minutes, restart" guidance) -- rich enough in distinct splash frames that
|
|
two DIFFERENT independent checks (nav-bar shape, then header-bar
|
|
brightness) could each individually be fooled by a different frame within
|
|
the same run, even though neither check alone is easily fooled.
|
|
|
|
Rather than chasing a fourth, even-stronger single-snapshot probe,
|
|
`_wait_for_home` now re-verifies `_true_home` a second time, after a short
|
|
wait, before finally trusting a passed `_connectivity_confirmed` check --
|
|
two DIFFERENT coincidental frames independently satisfying the full check
|
|
sequence twice in a row, moments apart, is far less likely than either
|
|
false positive alone. The stuck session itself was cleared with a manual
|
|
kill+relaunch (the user's own direct guidance) rather than continuing to
|
|
poke at an already-anomalous, hours-old stuck state. Deployed; not yet
|
|
re-confirmed live against a fresh instance of either original failure mode
|
|
end-to-end (a truly dead session, or the ordinary transition-frame case),
|
|
though the double-confirmation logic itself follows directly from live
|
|
evidence, not guesswork.
|
|
|
|
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 _connectivity_confirmed(driver, config):
|
|
"""Opens the Mission panel (a cheap, always-available screen, same icon
|
|
stamina.py uses) and closes it again, to prove the session is actually
|
|
live -- not just that this exact screenshot happens to satisfy
|
|
_true_home's checks. Two distinct real failure modes this catches:
|
|
|
|
1. A bright full-screen transition/wipe frame between loading and home
|
|
can transiently satisfy every _true_home probe at once (confirmed
|
|
live 2026-07-17: a real run printed "reached home" while still
|
|
mid-transition -- no HUD at all, a tower splash-art background --
|
|
moments before the real home screen actually rendered).
|
|
2. A genuinely dead session: the server's own daily reset around 4 AM
|
|
JST, or a concurrent login from another device, leaves the client
|
|
showing a cached-looking, visually normal home screen with no error
|
|
until an actual navigation/API call is attempted -- at which point
|
|
the game's own "connection lost" popup appears instead of the
|
|
requested screen. Reported live by the user with real cron logs:
|
|
after one such silent-death login, EVERY task that ran afterward
|
|
(cafe, event_sweep, circle, lesson, arena, both shops, gem_shop,
|
|
mailbox, stamina) failed to open its own screen, each in its own
|
|
inconsistent way, across multiple consecutive cron fires spanning
|
|
hours -- because nothing had verified the session was actually
|
|
alive before handing off to them.
|
|
|
|
Catching either here, once, right after login, is far more reliable
|
|
than leaving it to be discovered piecemeal by whichever task happens to
|
|
run next. If the click doesn't open the panel, we don't know whether
|
|
that's cafe-empty-air (transition frame, will resolve) or a real
|
|
connection-error dialog -- either way we just report failure and let
|
|
_wait_for_home's own loop retry the whole cycle (including this probe)
|
|
on its next iteration, exactly like any other not-yet-home state.
|
|
"""
|
|
# is_on_subscreen (a single-pixel check) was tried first and also
|
|
# false-positived live (2026-07-17): during an extended, multi-frame
|
|
# animated loading sequence, a *different* coincidental frame satisfied
|
|
# the single subscreen-header pixel right after the click, on the same
|
|
# run whose _true_home check had already been fooled by yet another
|
|
# frame. navigation.is_header_bar_visible (8 spread-out points, all
|
|
# must read bright) is the same "harder to fool by a single coincidental
|
|
# bright pixel" upgrade this project already made for cafe.py's rank-up
|
|
# detection -- reused here for the identical reason.
|
|
driver.click(*config.MISSION_ICON)
|
|
driver.wait(2)
|
|
opened = navigation.is_header_bar_visible(driver)
|
|
if opened:
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
return opened
|
|
|
|
|
|
def _wait_for_home(driver, config):
|
|
start = time.time()
|
|
while time.time() - start < config.LOGIN_TIMEOUT_SECONDS:
|
|
if _true_home(driver, config):
|
|
if not _connectivity_confirmed(driver, config):
|
|
print("[login] looked like home but the mission panel wouldn't open -- possible transition frame or dead session, retrying")
|
|
driver.wait(config.LOGIN_POLL_INTERVAL)
|
|
continue
|
|
# Even the 8-point header check was fooled live (2026-07-17,
|
|
# same session): an extended, highly-varied animated loading
|
|
# sequence apparently cycles through enough different splash
|
|
# frames that occasionally ONE frame coincidentally satisfies
|
|
# _true_home and a LATER, DIFFERENT frame coincidentally
|
|
# satisfies the connectivity probe too -- two independent
|
|
# false positives stacking, confirmed by an immediate follow-up
|
|
# check moments later reading false on all three signals at
|
|
# once. A single coincidental frame is unlikely; two DIFFERENT
|
|
# coincidental frames in a row, moments apart, is much less
|
|
# likely still -- so re-verify _true_home again after a short
|
|
# wait before finally trusting it.
|
|
driver.wait(config.LOGIN_POLL_INTERVAL)
|
|
if _true_home(driver, config):
|
|
return True
|
|
print("[login] home check didn't hold on re-verification -- treating as a coincidental frame, retrying")
|
|
continue
|
|
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")
|