508 lines
26 KiB
Python
508 lines
26 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), 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).
|
|
|
|
Per explicit user direction (2026-08-13): room 1 invites the
|
|
HIGHEST-affection candidate (the original, and still the default), room 2
|
|
invites the LOWEST-affection one instead -- `_invite_student`'s
|
|
`prefer_highest` flag controls this by sorting the MomoTalk list ascending
|
|
rather than descending before the same row-walking logic runs (see
|
|
`_ensure_invite_sort`).
|
|
|
|
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, image=None):
|
|
# 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.
|
|
# `image`, if given, is used only for the very first check -- everything
|
|
# after that point may have pressed Enter (real state change), so every
|
|
# later check must re-capture rather than keep reusing a now-stale frame.
|
|
first_check_image = image
|
|
for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES):
|
|
if navigation.is_header_bar_visible(driver, image=first_check_image):
|
|
return True
|
|
first_check_image = None
|
|
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.
|
|
#
|
|
# One capture per iteration shared between the rank-up check and the
|
|
# sparkle match (previously two separate scrot calls back-to-back with
|
|
# nothing in between): the header-bar check only samples fixed chrome
|
|
# pixels unrelated to the room scene, and the sparkle match already
|
|
# reports "wherever it is in this one frame" regardless of whether that
|
|
# frame is shared -- the room animating (a student walking around)
|
|
# doesn't change that, since neither read depends on the other lagging
|
|
# or leading it. See plan.md's "Performance improvement plan".
|
|
patted = 0
|
|
for _ in range(config.CAFE_MAX_CLICKS_PER_ROOM):
|
|
image = detector.capture_screen()
|
|
if not _dismiss_rank_up_if_shown(driver, config, image=image):
|
|
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(image=image)
|
|
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)
|
|
# No image= here -- the click/Return/mouse-move above are real state
|
|
# changes since this iteration's capture, so this check must be fresh.
|
|
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 _invite_name_rect(config, row_index):
|
|
row_y = config.CAFE_INVITE_ROW_Y[row_index]
|
|
x1, x2 = config.CAFE_INVITE_NAME_RECT_X
|
|
y_top, y_bottom = config.CAFE_INVITE_NAME_RECT_Y_OFFSET
|
|
return (x1, row_y + y_top, x2, row_y + y_bottom)
|
|
|
|
|
|
def _read_invite_name(driver, config, row_index):
|
|
# psm=7 (single line) badly garbled short 2-character names live --
|
|
# "ミカ" read back as "' ーー、テテー", most likely because the
|
|
# rect's generous fixed width (sized to fit the longest sampled name,
|
|
# "ナギサ(水着)") leaves mostly blank space around a short one, which
|
|
# psm=7 seems to misparse as further line content. psm=8 (single word)
|
|
# read all 5 rows sampled live correctly, at the cost of a little stray
|
|
# leading/trailing punctuation noise ("、ミカ (水着) 。", "-ミカー"),
|
|
# which the strip() below cleans up.
|
|
text = detector.read_text(_invite_name_rect(config, row_index), psm=8, lang="jpn")
|
|
return text.strip(" 、。-ー'\"") or None
|
|
|
|
|
|
def _ensure_invite_sort(driver, config, descending=True):
|
|
# 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)
|
|
|
|
# Rather than reading the direction-toggle icon's own arrow glyph,
|
|
# compare the top two rows' actual OCR'd affection values -- if they
|
|
# disagree with the requested direction, one toggle click flips it.
|
|
# 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 None or second is None:
|
|
return
|
|
currently_ascending = top < second
|
|
if descending and currently_ascending:
|
|
print(f"[cafe] invite list sorted ascending ({top} < {second}) -- toggling to descending")
|
|
driver.click(*config.CAFE_INVITE_SORT_DIRECTION_TOGGLE)
|
|
driver.wait(1)
|
|
elif not descending and not currently_ascending:
|
|
print(f"[cafe] invite list sorted descending ({top} >= {second}) -- toggling to ascending")
|
|
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 a (result, name) pair -- result is "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); name is the row's OCR'd student name (or
|
|
None if unreadable), read before the click since the list (and the
|
|
name text with it) is gone once a dialog is open.
|
|
"""
|
|
name = _read_invite_name(driver, config, row_index)
|
|
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", name
|
|
|
|
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
|
|
print(f"[cafe] row {row_index} ('{name}') 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", name
|
|
|
|
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
|
driver.wait(2)
|
|
return "invited", name
|
|
|
|
|
|
def _invite_student(driver, config, prefer_highest=True):
|
|
"""Invite an available student into the current room, before farming
|
|
it -- the highest-affection candidate when prefer_highest is True
|
|
(room 1), the lowest when False (room 2, per explicit user direction
|
|
2026-08-13). Tries up to len(CAFE_INVITE_ROW_Y) visible candidates
|
|
(matching the reference's own invite_by_affection bound -- no
|
|
scrolling) from whichever end of the affection order was requested,
|
|
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, descending=prefer_highest)
|
|
|
|
invited = False
|
|
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
|
|
result, name = _try_invite_row(driver, config, row_index)
|
|
if result == "invited":
|
|
print(f"[cafe] invited {name or f'row {row_index}'} (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 (highest affection)")
|
|
_invite_student(driver, config, prefer_highest=True)
|
|
|
|
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 (lowest affection)")
|
|
_invite_student(driver, config, prefer_highest=False)
|
|
|
|
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.")
|