"""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.")