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_SPARKLE_TEMPLATE = os.path.join(ASSET_DIR, "cafe_sparkle.png")
# A pat that crosses an affection-rank threshold shows a full-screen "絆ラン
# クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all --
# confirmed against screenshots/cafe/student/01-02: navigation.is_on_subscreen's
# header probe reads (183,220,240) there (r<200, fails) vs (248,249,250) on
# the normal cafe screen (r>200, passes), so the existing header-brightness
# check already tells the two apart. Bounds how many Enter presses
# _dismiss_rank_up_if_shown will try before giving up.
# クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all.
# Originally believed navigation.is_on_subscreen's single-pixel header probe
# reliably told this apart from the real cafe screen (confirmed against
# screenshots/cafe/student/01-02 at the time), but a real 2026-07-15 run hit
# a character whose cutscene art happened to read bright at that exact
# 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
# 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)
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):
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
# 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):
# "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

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
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
it via SWEEP_CONFIRM_BUTTON and returns False (skip invite this room)
instead of proceeding.
it and returns False (skip invite this room) 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
@ -81,12 +107,25 @@ def _dismiss_rank_up_if_shown(driver, config):
# returning None against it (a full-screen character portrait, nothing
# 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.
#
# 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):
if navigation.is_on_subscreen(driver):
if navigation.is_header_bar_visible(driver):
return True
driver.keypress("Return")
driver.wait(1.5)
return navigation.is_on_subscreen(driver)
return navigation.is_header_bar_visible(driver)
def _pat_current_view(driver, config):
@ -201,8 +240,30 @@ def _open_invite_list(driver, config):
if navigation.is_modal_open(driver):
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")
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1)
# 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)
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
if not navigation.is_on_subscreen(driver):
return True