"""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. """ 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). for attempt in range(1, ROOM_OPEN_RETRIES + 1): driver.click(*config.CAFE_INVITE_TICKET_ICON) driver.wait(2) 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.")