Nik Afiq 68a7ed2529 feat: cafe lowest-affection invite for room 2, exit_game AP-left-unspent alert, login window-flicker fix
- cafe.py: room 2 now invites the lowest-affection candidate instead of
  highest (room 1 unchanged), plus OCR'd invited-student name logging
- exit_game.py: alert (AP_ALERT: log tag, picked up by ba_cron_run.sh's
  Discord alerting) if AP is still unspent above threshold when the game
  closes
- login.py: re-check window_exists() after the stabilization wait to catch
  a startup window-flicker race that could crash a run
- config.py/mapping.md: supporting constants and reference-mapping updates

Also rewrites plan.md: retires the phase-by-phase changelog (now fully
superseded by CLAUDE.md's own documentation) down to forward-looking
backlog items, and adds a performance-improvement plan from a cron-log
time analysis (lesson.py's per-cell screenshot redundancy, event_sweep's
multi-call design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:28:59 +09:00

491 lines
24 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):
# 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 _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.")