Nik Afiq 68a7ed2529 feat: cafe lowest-affection invite for room 2, exit_game AP-left-unspent alert, login window-flicker fix
- cafe.py: room 2 now invites the lowest-affection candidate instead of
  highest (room 1 unchanged), plus OCR'd invited-student name logging
- exit_game.py: alert (AP_ALERT: log tag, picked up by ba_cron_run.sh's
  Discord alerting) if AP is still unspent above threshold when the game
  closes
- login.py: re-check window_exists() after the stabilization wait to catch
  a startup window-flicker race that could crash a run
- config.py/mapping.md: supporting constants and reference-mapping updates

Also rewrites plan.md: retires the phase-by-phase changelog (now fully
superseded by CLAUDE.md's own documentation) down to forward-looking
backlog items, and adds a performance-improvement plan from a cron-log
time analysis (lesson.py's per-cell screenshot redundancy, event_sweep's
multi-call design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:28:59 +09:00

535 lines
31 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.
**Faster stuck-loading detection, `_loading_buffer_visible` (2026-07-19)**:
the user manually ran `ba_cron_run.sh daily` and reported the game stuck at
login; by the time it was checked, the run was still well within its own
first `LOGIN_TIMEOUT_SECONDS` (240s) budget -- not actually broken, just
early in a bounded wait that hadn't reached its own timeout/recover point
yet (confirmed: `_recover`'s existing kill+relaunch DID fire once that
budget elapsed, per the user's own follow-up "Oh the game did relaunched").
But blindly waiting out the full generic timeout for a state this
recognizable is slower than it needs to be. Live-captured the real stuck
screen (`screenshots/daily_login/stuck_loading_buffer.png`, pulled via `scrot` over ssh) --
exactly state 3's full-bleed loading variant from this docstring's own
account above, rotating splash art with a small gray spinner badge dead
center. Pixel-probed it (`scratchpad/probe_login_buffer.py`): the badge's
left/right edges read a flat, exact neutral gray `(118,118,118)` --
R==G==B, unlike any sampled splash-art pixel elsewhere on the same
screenshot -- consistently across its whole vertical span, centered on the
screen's own true center. Since this is a fixed UI overlay independent of
whichever splash-art frame happens to be showing behind it, it doesn't
share the "different frame fools a different single-snapshot check" failure
class that already bit `_true_home`/`_connectivity_confirmed` twice (see
above). `_wait_for_home` now tracks how long this specific badge has been
seen continuously and bails out to `_recover` after
`LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s, well above the "brief" loading
variant's few-second normal case, far below the generic 240s budget) rather
than waiting out the full generic timeout once this exact, well-understood
bad state is confidently recognized. Verified offline against the real
capture (all 6 probe points read within tolerance of the target). Not yet
live-confirmed against a fresh real stuck instance recovering via this
faster path specifically (the instance that prompted this fix had already
recovered via the pre-existing generic-timeout path by the time the fix was
written).
**Pending-update download notice, `_update_dialog_open` (2026-08-04)**: the
user reported "the game contains a pending update... login doesn't adapt to
the popup," and a real q4h cron run that same day (17:00 JST) confirmed it
first-hand -- `~/ba_logs/q4h.log` showed `[login] not home after the
timeout -- killing and relaunching the game` followed by `could not reach
home after 2 attempts`, then every subsequent task (cafe, event_sweep, ...)
failed to open its own screen, the same cascading-failure shape as the
2026-07-17 session-death incident above but with a different root cause.
By the time this was investigated the game had been fully killed (exhausted
relaunch budget), so it was relaunched deliberately to reproduce the state
live rather than guessed at. A real "お知らせ" (Notice) dialog appeared
immediately after launch, well before the title screen, reading
"必要なコンポーネントをダウンロードします。" with a "容量"/MB size box and
キャンセル/OK buttons (`scratchpad/login_probe_01.png`) -- this is the same
notice class as the reference's own `main_page_game-download-resource-
notice[23]`/`main_page_download-additional-resources` img_reactions in
`core/Baas_thread.py::to_main_page`, which the reference answers with a
fixed-coordinate click on a matched image template; this project has no
template asset for it, so OCR on the body text is used instead (matching
CLAUDE.md's OCR policy of porting the reference's actual state-detection
approach rather than substituting a pixel-probe/coordinate guess). This
dialog previously fell through to the generic Enter-fallback branch like
every other unrecognized state -- confirmed live that Enter does NOT
dismiss it, unlike every other one-off notice this flow has hit so far,
which is exactly why the stuck cron run never resolved within
`LOGIN_TIMEOUT_SECONDS` and had to be killed and relaunched, only to hit
the same still-pending update again on relaunch. Fixed with
`_update_dialog_open`, an OCR check (`config.LOGIN_UPDATE_DIALOG_TEXT_RECT`,
lang="jpn") for the "ダウンロード" keyword in the dialog body, gating a
real click on `config.LOGIN_UPDATE_OK_BUTTON` -- inserted into
`_wait_for_home`'s existing dispatch chain gated on `_logo_state` reading
"absent" (rather than as its own check on every iteration), since it can
only matter on the exact branch that was getting stuck; "bright"/"dimmed"
are already known to be the plain title screen. Clicking OK triggers a real
~230MB download with its own progress readout, then a "ゲームデータを
イニシャライズしています……" (initializing) screen
(`scratchpad/login_probe_02.png`/`_03.png`), landing back on the ordinary
title screen afterward (`_04.png`) -- no new handling needed for either
intermediate screen, since neither has an interactive element and both
already fall through harmlessly to the same Enter fallback (a no-op on a
non-interactive splash screen) until the title screen's own bright-logo
check takes over. **Confirmed live end-to-end twice the same day**: first
via direct `xdotool`/`scrot` calls to calibrate the OCR rect/keyword and
button coordinate against the real dialog before writing any code, then
again for real via the actual deployed code path -- after this fix was
rsynced to nik-gpu, a real `./ba_dailies.sh login` run hit an unrelated
stuck-loading-buffer timeout, killed and relaunched the game, and the
still-pending update notice reappeared on that fresh relaunch. The log
shows it plainly: `[login] update/download notice detected -- clicking OK`
immediately followed by `[login] reached home`, and a follow-up screenshot
confirmed a genuine, fully-interactive home screen (full HUD: AP, credits,
gems, nav bar). Both the detection (`_update_dialog_open`) and the click
(`LOGIN_UPDATE_OK_BUTTON`) fired correctly on their first real,
un-rehearsed encounter through the normal dispatch path -- not just the
manual walkthrough used to calibrate them.
"""
import time
from ba_auto import detector, 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 _loading_buffer_visible(driver, config):
"""Positive detector for the full-bleed loading transition's center
spinner badge (see config.py's LOGIN_LOADING_BUFFER_* comment for the
live pixel calibration) -- confirms we're in that specific known-can-
get-stuck state rather than one of the many other unrecognized dialogs
the generic Enter-fallback handles, so _wait_for_home can bail out to
_recover on a much shorter, purpose-specific timeout instead of the
full generic wall-clock budget.
"""
colors = driver.colors_at(config.LOGIN_LOADING_BUFFER_PROBES)
return all(_color_matches(c, config.LOGIN_LOADING_BUFFER_RGB, config.LOGIN_LOADING_BUFFER_TOLERANCE) for c in colors)
def _update_dialog_open(driver, config):
"""Positive OCR check for the pending-update/additional-data download
notice (see config.py's LOGIN_UPDATE_DIALOG_* comment for the full
live-incident writeup and calibration). Only called when _logo_state
reads "absent" -- "bright"/"dimmed" are already known to be the plain
title screen, not this dialog, so there's no reason to pay for an OCR
call on every single poll.
"""
text = detector.read_text(config.LOGIN_UPDATE_DIALOG_TEXT_RECT, psm=6, lang="jpn")
return config.LOGIN_UPDATE_DIALOG_KEYWORD in text
def _true_home(driver, config):
return (
navigation.is_home_nav_bar_visible(driver)
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()
buffer_since = None
while time.time() - start < config.LOGIN_TIMEOUT_SECONDS:
if _loading_buffer_visible(driver, config):
if buffer_since is None:
buffer_since = time.time()
elif time.time() - buffer_since >= config.LOGIN_LOADING_BUFFER_STUCK_SECONDS:
print(f"[login] loading buffer stuck for {config.LOGIN_LOADING_BUFFER_STUCK_SECONDS}s+ -- not waiting out the full timeout")
return False
else:
buffer_since = None
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
logo_state = _logo_state(driver, config)
if _news_dialog_open(driver, config):
driver.click(*config.LOGIN_NEWS_CLOSE_BUTTON)
elif logo_state == "bright":
driver.click(*config.LOGIN_TOUCH_TO_START)
elif logo_state == "absent" and _update_dialog_open(driver, config):
# Pending-update/additional-data download notice -- does NOT
# respond to Enter (confirmed live 2026-08-04, see module
# docstring), unlike every other dialog in this else-branch, so
# it needs its own real button click rather than falling
# through to the generic fallback below. Gated on logo_state
# == "absent" (rather than checked unconditionally) so the OCR
# call is only paid for on the one branch it can actually
# matter on -- "bright"/"dimmed" are already known to be the
# plain title screen, not this dialog.
print("[login] update/download notice detected -- clicking OK")
driver.click(*config.LOGIN_UPDATE_OK_BUTTON)
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):
# A window that satisfies the check below can still flicker away again
# during game/Proton startup before it settles into the real, persistent
# window -- confirmed live (2026-08-07, q4h cron traceback): the old
# version returned True right after ONE window_exists() hit plus a fixed
# 3s wait, but the window had disappeared again by the time the caller's
# very next line (driver.focus_game()) ran its own independent check,
# raising an uncaught RuntimeError and crashing the whole run. Re-check
# window_exists() again after the stabilization wait, and keep polling
# instead of trusting a stale True, so callers only ever get True for a
# window confirmed present twice a few seconds apart.
for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS):
if driver.window_exists():
driver.wait(3)
if driver.window_exists():
return True
continue
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
if not driver.window_exists():
# Same flicker race as run() below, checked again right before the
# call that would otherwise crash -- matches the established
# window_exists()-then-focus_game() convention in navigation.py's
# return_to_home/click_back.
print("[login] warning: game window disappeared again right before focus -- giving up")
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
if not driver.window_exists():
print("[login] warning: game window disappeared again right before focus -- 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")