fix(login): enhance connectivity checks to prevent false positives in home detection

- 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.
This commit is contained in:
Nik Afiq 2026-07-17 20:07:48 +09:00
parent 4857bd5f8f
commit d21aadbde8
4 changed files with 172 additions and 2 deletions

File diff suppressed because one or more lines are too long

View File

@ -113,6 +113,88 @@ 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
@ -172,11 +254,81 @@ def _true_home(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":

18
plan.md
View File

@ -859,6 +859,24 @@ Times are nik-gpu's local system time, confirmed `Asia/Tokyo` (JST) via `timedat
**Follow-up, same day**: per explicit user request ("I also want to have logs for the cron. So I can detect if anythings go wrong. daily and q4h should have separate .log file") -- the per-preset log file separation was already in place (`~/ba_logs/<preset>.log`, one file per `$PRESET`), but a real gap was found in the failure-detection half of the ask: a genuinely crashed task and a lock-skip (an overlapping fire time, expected/harmless) both exit 1, and the original wrapper logged only a bare `(exit N)`, making them indistinguishable at a glance. Fixed by switching from the `flock -n LOCKFILE COMMAND` form to an fd-based `flock` (`exec 9>"$LOCK_FILE"; flock -n 9`), checking lock acquisition as its own explicit step separate from the wrapped command's own exit code, so every log line is now tagged with one of three explicit outcomes: `starting`/`finished ... OK (exit 0)`/`finished ... FAILED (exit N)`/`SKIPPED ... -- previous run still in progress` -- a real problem can now be found with `grep -E 'FAILED|SKIPPED' ~/ba_logs/*.log` alone, without having to reason about ambiguous exit codes. **Verified on nik-gpu without touching the game**: a bogus-preset run correctly logged `FAILED (exit 1)`; a deliberately-forced lock-contention run (same technique as the original test) correctly logged `SKIPPED ... -- previous run still in progress` instead of a second ambiguous `(exit 1)`; the grep command above correctly surfaced both lines from `~/ba_logs/smoke_test_bad.log`. Test log removed afterward -- `~/ba_logs/` currently contains no `daily.log`/`q4h.log` yet, since no real scheduled fire has happened.
### Phase 18 follow-up #3: connectivity probe -- session death and false-positive home detection (2026-07-17)
Cron's real scheduled fires had now happened (`daily` at 3:30/4:30, `q4h` every 4h -- see follow-up #2). The user pulled the actual logs with `pull_logs.sh` and reported: the 4:30 AM `daily` fire logged a clean `[login] reached home`, but then EVERY 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).
The user's own direct knowledge of the game's real behavior explained it: the server's own daily reset (~4 AM JST) or a concurrent login from another device (their 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 screen-detection logic recognizes. `login.py`'s `_true_home` 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, and every downstream task discovered the same root cause independently, inconsistently, over hours. The user's own suggested fix: "change the login to enter the stamina claim/reward area then go back home."
The user then offered to log into the account on their phone right now to reproduce the failure live, which immediately kicked the PC client on nik-gpu into a real stuck-login-screen state (confirmed via screenshot -- a full-bleed loading/splash frame, no HUD), matching the user's own prior guidance: "if this doesn't solve after 3~5 mins restarting the game is best way."
Implemented `_connectivity_confirmed`: opens the Mission panel (`config.MISSION_ICON`, the same icon `stamina.py` uses) and closes it again, required to pass before `_wait_for_home` will return success. This directly catches a dead session (the game's own connection-error dialog appears instead of the panel) -- and, it turned out, ALSO an entirely separate false positive: a bright, non-interactive loading/transition frame can transiently satisfy `_true_home`'s checks. Three live iterations were needed against the real stuck session before this actually held:
1. First version verified the panel opened via `navigation.is_on_subscreen` (single-pixel). Live-tested against the still-stuck session: `login` printed `[login] reached home`, but an independent follow-up screenshot taken moments later showed the game still on a *different* loading/transition frame (tower splash art, zero HUD) -- a coincidental false positive, not a real recovery.
2. Switched to `navigation.is_header_bar_visible` (8 spread-out points, all must read bright) -- the same upgrade this project already made for cafe.py's rank-up detection, for the identical "harder to fool by one coincidental bright pixel" reason. Re-tested live against the SAME still-stuck session: still a false positive, confirmed by an immediate independent re-check (screenshot plus a direct re-run of `_true_home`/`is_header_bar_visible`/`_connectivity_confirmed`, all reading false) on a THIRD different splash frame.
3. 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 -- rich enough in distinct splash frames that two independent checks (nav-bar shape, then header-bar brightness) could each individually be fooled by a *different* frame within the same run. 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 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. **Confirmed live**: a genuinely fresh cold-start `login` run (game process not running at all, via `pkill` then `login.py`'s own launch-if-missing path) correctly launched the game, passed through the title screen, and reached independently-verified true home -- full HUD (Lv/AP/credits/gems/bottom nav all visible), confirmed via a direct screenshot AND a separate `_true_home`/`is_header_bar_visible` check run independently, not just the module's own printed message.
Not yet re-confirmed: a fresh instance of the *original* reported failure mode specifically -- a session that silently dies while still looking exactly like home, discovered only when a later task tries to navigate (the cron-log scenario). The live reproduction available this session was the full-kick-to-login-screen variant (triggered by the phone login), not that exact "looks fine, breaks on navigation" variant -- though `_connectivity_confirmed` is designed to catch both by the same mechanism (a real API round-trip, not a visual check), and the false positives it exposed and fixed along the way are a strict improvement regardless.
## Prerequisites
### OCR

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB