348 lines
15 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 via SWEEP_CONFIRM_BUTTON and returns False (skip invite this room)
instead of proceeding.
"""
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.
for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES):
if navigation.is_on_subscreen(driver):
return True
driver.keypress("Return")
driver.wait(1.5)
return navigation.is_on_subscreen(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")
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1)
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.")