fix(cafe): resolve invite ticket cooldown handling and improve rank-up cutscene detection

This commit is contained in:
Nik Afiq 2026-07-15 14:09:55 +09:00
parent a0e1965353
commit bb0215cb04
5 changed files with 127 additions and 13 deletions

View File

@ -32,12 +32,15 @@ CAFE_INCOME = (1780, 1105)
CAFE_MAX_CLICKS_PER_ROOM = 15 CAFE_MAX_CLICKS_PER_ROOM = 15
CAFE_SPARKLE_TEMPLATE = os.path.join(ASSET_DIR, "cafe_sparkle.png") CAFE_SPARKLE_TEMPLATE = os.path.join(ASSET_DIR, "cafe_sparkle.png")
# A pat that crosses an affection-rank threshold shows a full-screen "絆ラン # A pat that crosses an affection-rank threshold shows a full-screen "絆ラン
# クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all -- # クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all.
# confirmed against screenshots/cafe/student/01-02: navigation.is_on_subscreen's # Originally believed navigation.is_on_subscreen's single-pixel header probe
# header probe reads (183,220,240) there (r<200, fails) vs (248,249,250) on # reliably told this apart from the real cafe screen (confirmed against
# the normal cafe screen (r>200, passes), so the existing header-brightness # screenshots/cafe/student/01-02 at the time), but a real 2026-07-15 run hit
# check already tells the two apart. Bounds how many Enter presses # a character whose cutscene art happened to read bright at that exact
# _dismiss_rank_up_if_shown will try before giving up. # pixel, misreading the cutscene as already cleared -- see cafe.py's
# _dismiss_rank_up_if_shown and navigation.is_header_bar_visible for the
# fix (many header-row points must all read bright, not just one). Bounds
# how many Enter presses _dismiss_rank_up_if_shown will try before giving up.
CAFE_RANK_UP_DISMISS_RETRIES = 5 CAFE_RANK_UP_DISMISS_RETRIES = 5
# Horizontal camera panning before farming, per explicit user direction # Horizontal camera panning before farming, per explicit user direction

View File

@ -105,5 +105,25 @@ def color_at(x, y):
return int(r), int(g), int(b) return int(r), int(g), int(b)
def colors_at(points):
"""Sample multiple (x, y) points from a single screenshot, instead of one
scrot capture per point -- added for navigation.is_header_bar_visible's
multi-point header check, which otherwise called color_at() 8 times (8
full-screen captures) for one logical check. Also more correct than
looping color_at(): all points come from the exact same frame rather
than points sampled sequentially across several hundred ms of separate
captures, which could straddle a screen transition.
Returns a list of (r, g, b) tuples in the same order as `points`.
"""
screenshot(PROBE_SHOT_PATH)
image = cv2.imread(PROBE_SHOT_PATH)
colors = []
for x, y in points:
b, g, r = image[y, x]
colors.append((int(r), int(g), int(b)))
return colors
def wait(seconds): def wait(seconds):
time.sleep(seconds) time.sleep(seconds)

View File

@ -28,6 +28,36 @@ def is_modal_open(driver):
return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL
# Every confirmed subscreen (mailbox/cafe/shop/lesson/event) renders a
# uniform light header bar spanning nearly the full screen width at this y --
# is_on_subscreen only samples one x on that row (SUBSCREEN_HEADER_PROBE),
# which is cheap and has been fine for ordinary subscreen-vs-home checks, but
# was found live (2026-07-15, cafe.py's rank-up dismiss loop) to misread a
# full-screen "絆ランクアップ!" cutscene as "already back on subscreen" for
# some characters -- the single x=500 sample happened to land on a bright
# patch of that character's own art/background, not the real header. A real
# header bar is flat and uniform across its whole width; a photo-real
# cutscene composition (hair, uniform, the dark rank-up banner itself) is
# very unlikely to coincidentally read bright at MANY spread-out x offsets on
# the same row simultaneously. Used where that specific ambiguity matters
# (so far only cafe.py's rank-up dismiss) rather than swapped in for
# is_on_subscreen everywhere, to avoid changing already-working behavior at
# every other call site.
HEADER_ROW_Y = 10
HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700)
def is_header_bar_visible(driver):
# One screenshot for all 8 points (driver.colors_at) rather than 8
# separate color_at() calls -- cheaper, and atomic (every point comes
# from the same frame instead of drifting across ~8 sequential captures).
points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS]
for r, g, b in driver.colors_at(points):
if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL):
return False
return True
def _not_home(driver): def _not_home(driver):
# "Home" means neither on a subscreen NOR under an open modal. Checking # "Home" means neither on a subscreen NOR under an open modal. Checking
# only is_on_subscreen was found live (2026-07-11, ba_daily.py's # only is_on_subscreen was found live (2026-07-11, ba_daily.py's

File diff suppressed because one or more lines are too long

View File

@ -36,8 +36,34 @@ into a dialog that has none of them, repeatedly. Fixed by checking
navigation.is_modal_open (a real dialog's darker dim) before the navigation.is_modal_open (a real dialog's darker dim) before the
list-opened check: a dialog appearing before any row was clicked can only list-opened check: a dialog appearing before any row was clicked can only
mean the ticket click itself raised one directly, so this now dismisses mean the ticket click itself raised one directly, so this now dismisses
it via SWEEP_CONFIRM_BUTTON and returns False (skip invite this room) it and returns False (skip invite this room) instead of proceeding.
instead of proceeding.
Two further real-usage bugs, both reported 2026-07-15 (next_fix.md) and
both fixed:
1. The cooldown-notice dismiss above originally clicked
SWEEP_CONFIRM_BUTTON's fixed coordinate, which could miss the notice's
own button, leaving it open -- the camera-pan drags that followed then
landed on the still-open dialog instead of the room view, breaking that
room's farming. Switched to an Escape keypress, verified with
navigation.is_modal_open and retried up to ROOM_OPEN_RETRIES times rather
than assumed to work on the first press -- an unconfirmed keypress would
have reproduced the exact same "stuck dialog, blind pan drags" failure
through a different unverified assumption (caught in review before this
was live-tested).
2. _dismiss_rank_up_if_shown originally checked navigation.is_on_subscreen,
which samples a single header pixel -- for some characters' rank-up art
that pixel reads bright by coincidence, so the loop believed the cutscene
had already cleared without ever pressing Enter, then the pat loop kept
polling find_cafe_sparkle() against the still-showing cutscene for the
rest of its budget and missed further students. Switched to
navigation.is_header_bar_visible, which requires many spread-out
header-row x positions to all read bright -- much less likely to
coincidentally match a full-screen character composition than a single
point. Best-effort fix: could not force a live rank-up on demand to
confirm end-to-end, so treat as implemented-but-unverified until one
happens naturally during a real cafe run (same caveat this file already
carried for the original rank-up dismiss before it was live-confirmed).
""" """
from ba_auto import detector, navigation from ba_auto import detector, navigation
@ -81,12 +107,25 @@ def _dismiss_rank_up_if_shown(driver, config):
# returning None against it (a full-screen character portrait, nothing # returning None against it (a full-screen character portrait, nothing
# like the sparkle template) for the rest of the room's click budget. # like the sparkle template) for the rest of the room's click budget.
# That's the "freeze" -- not a timing fluke, a genuinely unhandled state. # That's the "freeze" -- not a timing fluke, a genuinely unhandled state.
#
# Real-usage bug (2026-07-15, next_fix.md bug 1): this originally checked
# navigation.is_on_subscreen, which samples a single header pixel -- for
# some characters' rank-up art that pixel reads bright by coincidence, so
# the loop believed the cutscene had already cleared on the very first
# check and returned True without ever pressing Enter. The pat loop then
# kept polling find_cafe_sparkle() against the still-showing cutscene for
# the rest of its budget, finding nothing, which is the "skipped instead
# of self-healing and missed further students" the user reported. Fixed
# by switching to navigation.is_header_bar_visible, which requires many
# spread-out header-row x positions to all read bright -- much less
# likely to coincidentally match a full-screen character composition than
# a single point. See navigation.py's own comment for the full reasoning.
for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES): for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES):
if navigation.is_on_subscreen(driver): if navigation.is_header_bar_visible(driver):
return True return True
driver.keypress("Return") driver.keypress("Return")
driver.wait(1.5) driver.wait(1.5)
return navigation.is_on_subscreen(driver) return navigation.is_header_bar_visible(driver)
def _pat_current_view(driver, config): def _pat_current_view(driver, config):
@ -201,8 +240,30 @@ def _open_invite_list(driver, config):
if navigation.is_modal_open(driver): if navigation.is_modal_open(driver):
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn") title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
print(f"[cafe] invite ticket click opened a dialog instead of the list (title: '{title}') -- likely on cooldown, treating invitation as unavailable") print(f"[cafe] invite ticket click opened a dialog instead of the list (title: '{title}') -- likely on cooldown, treating invitation as unavailable")
driver.click(*config.SWEEP_CONFIRM_BUTTON) # Real-usage bug (2026-07-15, next_fix.md bug 2): this originally
# clicked SWEEP_CONFIRM_BUTTON's fixed coordinate to dismiss the
# notice, but that position can miss (this is a plain single-OK
# notice, not necessarily laid out identically to the two-button
# confirm/cancel dialogs SWEEP_CONFIRM_BUTTON was calibrated
# against) -- a missed click left the notice open, and the
# camera-pan drags that followed landed on the still-open dialog
# instead of the room view, breaking that room's farming.
# navigation.py's own return_to_home docstring documents Escape
# as confirmed live to close dialogs in this project without
# confirming anything, but that has never specifically been
# confirmed against THIS single-OK notice variant -- an unverified
# keypress is no more trustworthy than the unverified click it
# replaced. So verify with is_modal_open and retry the press
# (bounded, matching this file's own click-then-verify
# convention) instead of assuming one Escape worked.
for dismiss_attempt in range(1, ROOM_OPEN_RETRIES + 1):
driver.keypress("Escape")
driver.wait(1) driver.wait(1)
if not navigation.is_modal_open(driver):
break
print(f"[cafe] cooldown notice still open after Escape (attempt {dismiss_attempt}/{ROOM_OPEN_RETRIES})")
else:
print("[cafe] could not confirm the cooldown notice closed -- room state may still be blocked")
return False return False
if not navigation.is_on_subscreen(driver): if not navigation.is_on_subscreen(driver):
return True return True