"""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). A real rank-up happened naturally via cron (2026-07-16, reported live with a screenshot of the game left stuck on the cutscene) and exposed a THIRD bug, a timing gap rather than a threshold problem: _dismiss_rank_up_if_shown was only ever called once, immediately after a pat, with no wait beforehand -- but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view (header genuinely still visible in that instant) and conclude "clear" a beat before the actual cutscene appeared. Confirmed live: the log showed the pat printing successfully (i.e. the post-pat check passed), then the following camera-pan drag produced no visible movement (dragging over a static cutscene image), and the game was left stuck on the cutscene even after the whole script finished -- navigation.return_to_home's generic cleanup also failed to recover it, since this specific cutscene's background happens to read under is_on_subscreen/is_modal_open's thresholds too (confirmed via a live screenshot pixel-check: SUBSCREEN_HEADER_PROBE read (180,227,244), r=180 < the 200 threshold both checks need), so every "are we home" check downstream falsely agreed nothing was wrong. Fixed by checking on every _pat_current_view poll iteration instead of only right after a pat -- see that function's own comment. """ 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. # # Real-usage bug (2026-07-16): the rank-up check used to run only once, # immediately after a pat, with no wait beforehand -- but the rank-up # cutscene was confirmed live to render with its own client-side # animation delay, so that single check could catch the tail end of the # still-normal room view (header genuinely still visible in that instant) # and conclude "clear" a beat before the actual cutscene appeared. Once # that happened, nothing ever checked again: find_cafe_sparkle() can't # tell "stuck on an undetected cutscene" apart from the ordinary # "nothing to pat right now" case, so the loop just polled dead air for # the rest of its budget, the following camera pan dragged over a static # cutscene image (no visible movement), and even navigation. # return_to_home's generic cleanup failed to recover afterward -- this # exact cutscene's background happens to read under is_on_subscreen/ # is_modal_open's thresholds too, so every "are we home" check downstream # falsely agreed nothing was wrong. Fixed by checking on EVERY poll # iteration, not just right after a pat, so a delayed appearance is # caught (and dismissed, via _dismiss_rank_up_if_shown's own Enter-press # loop) on the next iteration, roughly a second later, instead of never. patted = 0 for _ in range(config.CAFE_MAX_CLICKS_PER_ROOM): if not _dismiss_rank_up_if_shown(driver, config): print("[cafe] warning: cafe screen not confirmed (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly") break 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.")