409 lines
19 KiB
Python
409 lines
19 KiB
Python
"""Cafe daily task. Ported from baas-reference module/cafe_reward.py's state-probe pattern.
|
|
|
|
Student invitation (招待券), added 2026-07-14 per explicit user direction,
|
|
ports module/cafe_reward.py's invite_girl/invite_by_affection/
|
|
checkConfirmInvite: invite a student into each room before farming it (a
|
|
newly-invited student can be patted the same run), preferring the
|
|
HIGHEST-affection candidate among the first 5 visible in the MomoTalk list
|
|
(matching the reference's own invite_by_affection bound -- no scrolling),
|
|
and always skipping (never confirming) a candidate that would swap an
|
|
already-seated student's costume or move one in from the other room. This
|
|
directly ports the reference's own checkConfirmInvite behavior with its
|
|
default config (cafe_reward_allow_exchange_student/
|
|
cafe_reward_allow_duplicate_invite both False) -- there is no equivalent
|
|
config in this project to make either configurable, so both are always
|
|
disallowed. See config.py's "Cafe student invitation" section for the full
|
|
live-calibration writeup, including all 3 real dialog variants this was
|
|
confirmed against (zero real tickets spent during calibration).
|
|
|
|
Confirmed live with real tickets spent, both rooms, same day: room 1
|
|
correctly skipped one 衣装替え (costume-swap) candidate then invited row 1
|
|
cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待
|
|
(neighboring-room-move) candidates -- expected, since room 1's own invite
|
|
had just taken the account's highest-affection students -- then invited
|
|
row 3 cleanly. Both newly-invited students were immediately patted
|
|
successfully in the same run, income was claimed, and the task returned
|
|
cleanly to the true home screen with no warnings anywhere in the log.
|
|
|
|
A follow-up real-usage bug (2026-07-14, reported with a screenshot after
|
|
the ticket used above went on cooldown): _open_invite_list originally
|
|
verified only "did the header stop reading as a plain subscreen", which
|
|
is also true when the ticket is on cooldown and the click raises a
|
|
"通知" notice ("待機時間が経過した後に、再度招待することができます。")
|
|
directly instead of opening the list -- misread as "list opened", it then
|
|
sent _ensure_invite_sort/_try_invite_row's row/sort-control coordinates
|
|
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 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
|
|
|
|
ROOM_OPEN_RETRIES = 3
|
|
|
|
# "受取" (claim) renders as flat grey when there is nothing to collect yet.
|
|
CLAIM_PROBE = (960, 850)
|
|
CLAIM_DISABLED_RGB = (218, 218, 218)
|
|
CLAIM_DISABLED_TOLERANCE = 15
|
|
|
|
|
|
def _claim_disabled(driver):
|
|
r, g, b = driver.color_at(*CLAIM_PROBE)
|
|
tr, tg, tb = CLAIM_DISABLED_RGB
|
|
return (
|
|
abs(r - tr) <= CLAIM_DISABLED_TOLERANCE
|
|
and abs(g - tg) <= CLAIM_DISABLED_TOLERANCE
|
|
and abs(b - tb) <= CLAIM_DISABLED_TOLERANCE
|
|
)
|
|
|
|
|
|
def _enter_room(driver, coords):
|
|
for attempt in range(1, ROOM_OPEN_RETRIES + 1):
|
|
driver.click(*coords)
|
|
driver.wait(3)
|
|
if navigation.is_on_subscreen(driver):
|
|
# dismiss the "visited student list" notice shown on room entry
|
|
driver.keypress("Return")
|
|
driver.wait(1)
|
|
return True
|
|
print(f"[cafe] room not detected after click (attempt {attempt}/{ROOM_OPEN_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _dismiss_rank_up_if_shown(driver, config):
|
|
# The reference's own to_cafe() navigation (module/cafe_reward.py) treats
|
|
# 'relationship_rank_up' as a recognized, reactively-dismissed popup
|
|
# after every pat round -- this loop's original port had no equivalent,
|
|
# so a rank-up cutscene just sat there while find_cafe_sparkle() kept
|
|
# 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_header_bar_visible(driver):
|
|
return True
|
|
driver.keypress("Return")
|
|
driver.wait(1.5)
|
|
return navigation.is_header_bar_visible(driver)
|
|
|
|
|
|
def _pat_current_view(driver, config):
|
|
# Sparkles appear on a per-student cooldown, so most single checks find
|
|
# nothing -- the old Bash loop (and an earlier version of this one) gave
|
|
# up on the very first miss, which meant it essentially never farmed.
|
|
# Keep polling for the full budget instead of bailing early.
|
|
patted = 0
|
|
for _ in range(config.CAFE_MAX_CLICKS_PER_ROOM):
|
|
match = detector.find_cafe_sparkle()
|
|
if match is None:
|
|
driver.wait(1)
|
|
continue
|
|
x, y, score = match
|
|
driver.click(x, y)
|
|
driver.wait(1)
|
|
driver.keypress("Return")
|
|
# park the cursor away from the sparkle area so it can't occlude the
|
|
# next detection screenshot (see screenshots/cafe/sparkle/02_*_cursor_on_head.png)
|
|
driver.move_mouse(10, 1190)
|
|
if not _dismiss_rank_up_if_shown(driver, config):
|
|
print("[cafe] warning: cafe screen not confirmed after a pat (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly")
|
|
break
|
|
patted += 1
|
|
print(f"[cafe] patted sparkle at ({x}, {y}), score={score:.3f}")
|
|
return patted
|
|
|
|
|
|
def _pan_camera(driver, config, start_x, end_x):
|
|
for _ in range(config.CAFE_PAN_DRAG_REPEATS):
|
|
driver.drag(start_x, config.CAFE_PAN_DRAG_Y, end_x, config.CAFE_PAN_DRAG_Y, duration=config.CAFE_PAN_DRAG_DURATION)
|
|
driver.wait(0.5)
|
|
|
|
|
|
def _pat_room(driver, config):
|
|
# Per explicit user direction (2026-07-14): the room is wider than what
|
|
# fits in one view on the user's screen, so a stationary scan can miss
|
|
# students sitting outside whatever slice happened to be visible when
|
|
# the room loaded. Pans the camera to its rightmost extreme, farms
|
|
# there, then to its leftmost extreme, farms there too -- deliberately
|
|
# no vertical pan, per the user's own instruction that it would mess
|
|
# with the view. See config.py's "Horizontal camera panning" comment
|
|
# for the live-confirmed drag-direction-to-reveal-side mapping.
|
|
total_patted = 0
|
|
|
|
_pan_camera(driver, config, config.CAFE_PAN_RIGHT_X, config.CAFE_PAN_LEFT_X)
|
|
print("[cafe] panned to rightmost extreme")
|
|
total_patted += _pat_current_view(driver, config)
|
|
|
|
_pan_camera(driver, config, config.CAFE_PAN_LEFT_X, config.CAFE_PAN_RIGHT_X)
|
|
print("[cafe] panned to leftmost extreme")
|
|
total_patted += _pat_current_view(driver, config)
|
|
|
|
print(f"[cafe] patted {total_patted} sparkle(s) total" if total_patted else "[cafe] no sparkle found")
|
|
|
|
|
|
def _claim_income(driver, config):
|
|
driver.click(*config.CAFE_INCOME)
|
|
driver.wait(2)
|
|
if not navigation.is_modal_open(driver):
|
|
print("[cafe] income panel not detected, skipping claim")
|
|
return
|
|
|
|
if _claim_disabled(driver):
|
|
print("[cafe] nothing to claim")
|
|
else:
|
|
print("[cafe] claiming income")
|
|
driver.keypress("Return")
|
|
driver.wait(2)
|
|
driver.keypress("Return")
|
|
driver.wait(2)
|
|
|
|
if navigation.is_modal_open(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
|
|
|
|
def _open_invite_list(driver, config):
|
|
# The MomoTalk list's own dimming overlay darkens navigation.
|
|
# is_on_subscreen's header probe the same way any subscreen-covering
|
|
# modal does (see navigation.py's own _not_home docstring for the
|
|
# general version of this) -- checked directly rather than via
|
|
# navigation.is_modal_open, which needs a darker reading than this
|
|
# list-with-no-nested-dialog state actually produces (confirmed live:
|
|
# MODAL_DIM_PROBE read (252,145,165) with just the list open, which
|
|
# fails is_modal_open's all-channels-under-150 check).
|
|
#
|
|
# But that same "not on subscreen" reading is also produced by a
|
|
# completely different case: when the ticket is on cooldown, clicking
|
|
# the icon does NOT open the list at all -- it raises a "通知" dialog
|
|
# directly ("待機時間が経過した後に、再度招待することができます。",
|
|
# confirmed live 2026-07-14 via a real user report + screenshot) with
|
|
# the same dimming. The original version of this check couldn't tell
|
|
# the two apart and treated the cooldown notice as "list opened",
|
|
# which then sent _ensure_invite_sort/_try_invite_row's fixed row/
|
|
# sort-control coordinates into a dialog that doesn't have any of
|
|
# them -- exactly the "keep pressing the invite when it's not
|
|
# available and causing this popup and fail" the user reported.
|
|
#
|
|
# A real dialog (navigation.is_modal_open, which needs the DARKER
|
|
# reading a nested "通知" card produces, not just the list's own lighter
|
|
# dim) can only legitimately appear here if the ticket click itself
|
|
# opened one directly -- the list view has no dialog on top of it yet
|
|
# at this point, since no row has been clicked. So checking
|
|
# is_modal_open first, before the list-opened check, cleanly
|
|
# distinguishes "cooldown notice fired instead of the list" from "the
|
|
# list opened normally" and lets this dismiss the notice safely rather
|
|
# than misreading it as success.
|
|
for attempt in range(1, ROOM_OPEN_RETRIES + 1):
|
|
driver.click(*config.CAFE_INVITE_TICKET_ICON)
|
|
driver.wait(2)
|
|
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")
|
|
# 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
|
|
print(f"[cafe] invitation ticket list not detected after click (attempt {attempt}/{ROOM_OPEN_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _invite_heart_rect(config, row_index):
|
|
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
|
hx, hy = config.CAFE_INVITE_HEART_OCR_HALF_SIZE
|
|
cx = config.CAFE_INVITE_HEART_X
|
|
return (cx - hx, row_y - hy, cx + hx, row_y + hy)
|
|
|
|
|
|
def _read_invite_affection(driver, config, row_index):
|
|
return detector.read_int_on_heart_badge(_invite_heart_rect(config, row_index))
|
|
|
|
|
|
def _ensure_invite_sort(driver, config):
|
|
# Explicitly (re-)select 絆ランク as the sort field every run, rather
|
|
# than trusting whatever a previous manual session left selected --
|
|
# mirrors the reference's own explicit change_order_type step.
|
|
driver.click(*config.CAFE_INVITE_SORT_FIELD_DROPDOWN)
|
|
driver.wait(1)
|
|
driver.click(*config.CAFE_INVITE_SORT_BOND_RANK_OPTION)
|
|
driver.wait(0.3)
|
|
driver.click(*config.CAFE_INVITE_SORT_OK_BUTTON)
|
|
driver.wait(1)
|
|
|
|
# Per explicit user direction: highest affection first. Rather than
|
|
# reading the direction-toggle icon's own arrow glyph, compare the top
|
|
# two rows' actual OCR'd affection values -- if row 0 reads lower than
|
|
# row 1, the list is sorted ascending and needs one toggle click. Reuses
|
|
# the same OCR path already needed to evaluate candidates, and is
|
|
# confirmed live in both directions (descending: 38,35; ascending: 1,2).
|
|
top = _read_invite_affection(driver, config, 0)
|
|
second = _read_invite_affection(driver, config, 1)
|
|
if top is not None and second is not None and top < second:
|
|
print(f"[cafe] invite list sorted ascending ({top} < {second}) -- toggling to descending")
|
|
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
|
driver.wait(1)
|
|
|
|
|
|
def _is_swap_or_move_warning(title):
|
|
return "衣装" in title or "隣" in title
|
|
|
|
|
|
def _try_invite_row(driver, config, row_index):
|
|
"""Click a row's 招待 button and resolve whatever dialog appears.
|
|
Returns "invited" (confirmed a real invite), "skipped" (a swap/move
|
|
warning was detected and cancelled without spending anything), or
|
|
"no_dialog" (the click didn't seem to open anything -- treated as a
|
|
miss, not a decision, so the caller stops rather than guess further).
|
|
"""
|
|
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
|
driver.click(config.CAFE_INVITE_BUTTON_X, row_y)
|
|
driver.wait(1.5)
|
|
if not navigation.is_modal_open(driver):
|
|
print(f"[cafe] invite click on row {row_index} did not open a dialog")
|
|
return "no_dialog"
|
|
|
|
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
|
|
print(f"[cafe] row {row_index} invite dialog title: '{title}'")
|
|
if _is_swap_or_move_warning(title):
|
|
print(f"[cafe] row {row_index} would swap an already-seated student's costume or move one in from the other room -- skipping")
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
return "skipped"
|
|
|
|
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
|
driver.wait(2)
|
|
return "invited"
|
|
|
|
|
|
def _invite_student(driver, config):
|
|
"""Invite the highest-affection available student into the current
|
|
room, before farming it. Tries up to len(CAFE_INVITE_ROW_Y) visible
|
|
candidates (matching the reference's own invite_by_affection bound --
|
|
no scrolling), stopping at the first one that invites cleanly; skips
|
|
(never confirms) any candidate that would swap an already-seated
|
|
student's costume or move one in from the other room.
|
|
"""
|
|
if not _open_invite_list(driver, config):
|
|
print("[cafe] could not open the invitation ticket list -- no ticket available or a click missed, skipping invite")
|
|
return
|
|
|
|
_ensure_invite_sort(driver, config)
|
|
|
|
invited = False
|
|
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
|
|
result = _try_invite_row(driver, config, row_index)
|
|
if result == "invited":
|
|
print(f"[cafe] invited row {row_index}")
|
|
invited = True
|
|
break
|
|
if result == "no_dialog":
|
|
break
|
|
|
|
if not invited:
|
|
print("[cafe] no candidate could be invited without swapping/moving an existing student")
|
|
|
|
# Return to the plain room view regardless of outcome.
|
|
if navigation.is_modal_open(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1)
|
|
if not navigation.is_on_subscreen(driver):
|
|
driver.click(*config.CAFE_INVITE_LIST_CLOSE_BUTTON)
|
|
driver.wait(1)
|
|
|
|
|
|
def run(driver, config):
|
|
driver.focus_game()
|
|
|
|
if not _enter_room(driver, config.CAFE_ICON):
|
|
print("[cafe] could not confirm cafe is open, aborting without pressing further keys")
|
|
return
|
|
|
|
print("[cafe] room 1: inviting a student if available")
|
|
_invite_student(driver, config)
|
|
|
|
print("[cafe] room 1: farming affection")
|
|
_pat_room(driver, config)
|
|
|
|
if not _enter_room(driver, config.CAFE_ROOM_SWITCH):
|
|
print("[cafe] could not confirm room switch, stopping before income claim")
|
|
if navigation.is_on_subscreen(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
return
|
|
|
|
print("[cafe] room 2: inviting a student if available")
|
|
_invite_student(driver, config)
|
|
|
|
print("[cafe] room 2: farming affection")
|
|
_pat_room(driver, config)
|
|
|
|
_claim_income(driver, config)
|
|
|
|
if navigation.is_on_subscreen(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
print("[cafe] Done.")
|