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>
This commit is contained in:
parent
e0744fc799
commit
68a7ed2529
@ -188,6 +188,18 @@ CAFE_INVITE_HEART_X = 758
|
||||
# building a second OCR path for what's confirmed to be the same widget.
|
||||
CAFE_INVITE_HEART_OCR_HALF_SIZE = (40, 23)
|
||||
|
||||
# Name text OCR rect, offset from row_y -- pixel-calibrated live 2026-08-13
|
||||
# against a real open invite list (scratchpad/probe_invite_list.png, since
|
||||
# deleted): the name sits directly above the heart badge, right of the
|
||||
# portrait. (700, 1050) on X clears the portrait on the left and stays well
|
||||
# short of the 招待 button on the right; (-70, -15) on Y (offset from
|
||||
# row_y) was confirmed by direct crop inspection against 5 real rows of
|
||||
# varying name length (short "ミカ" through longer "ナギサ(水着)") with
|
||||
# no clipping and only a harmless sliver of the heart badge's own top edge
|
||||
# creeping into the very bottom of the crop.
|
||||
CAFE_INVITE_NAME_RECT_X = (700, 1050)
|
||||
CAFE_INVITE_NAME_RECT_Y_OFFSET = (-70, -15)
|
||||
|
||||
# The dialog raised by clicking a row's 招待 button. Live-confirmed 3
|
||||
# distinct cases, all sharing the exact same "通知"-style dialog component
|
||||
# this project already uses everywhere else -- SWEEP_CONFIRM_BUTTON/
|
||||
@ -396,6 +408,14 @@ SWEEP_RESULT_BUTTON_REGION = (700, 700, 1300, 1050)
|
||||
# feasibility floor.
|
||||
SWEEP_MIN_AP = 20
|
||||
|
||||
# exit_game alerts (via ba_cron_run.sh's AP_ALERT log-grep, see plan.md
|
||||
# Phase 30) if AP is still above this when the game closes -- catches AP
|
||||
# quietly capping out unspent between cron fires. A separate constant from
|
||||
# SWEEP_MIN_AP above even though the value happens to match: that one is a
|
||||
# feasibility floor for attempting a sweep, this one is a "you left AP on
|
||||
# the table" waste threshold, and they're free to diverge independently.
|
||||
EXIT_AP_ALERT_THRESHOLD = 20
|
||||
|
||||
# (region, stage, count) targets to sweep, mirroring the reference's
|
||||
# unfinished_normal_tasks shape (module/explore_tasks/sweep_task.py). `stage`
|
||||
# is 1-5, or the string "A" for the bonus stage that only exists when
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -3,18 +3,25 @@
|
||||
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).
|
||||
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
|
||||
@ -324,7 +331,27 @@ 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):
|
||||
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.
|
||||
@ -335,18 +362,24 @@ def _ensure_invite_sort(driver, config):
|
||||
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
|
||||
# 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 not None and second is not None and top < second:
|
||||
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):
|
||||
@ -355,50 +388,57 @@ def _is_swap_or_move_warning(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).
|
||||
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"
|
||||
return "no_dialog", name
|
||||
|
||||
title = detector.read_text(config.CAFE_INVITE_DIALOG_TITLE_RECT, psm=6, lang="jpn")
|
||||
print(f"[cafe] row {row_index} invite dialog title: '{title}'")
|
||||
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"
|
||||
return "skipped", name
|
||||
|
||||
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
||||
driver.wait(2)
|
||||
return "invited"
|
||||
return "invited", name
|
||||
|
||||
|
||||
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.
|
||||
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)
|
||||
_ensure_invite_sort(driver, config, descending=prefer_highest)
|
||||
|
||||
invited = False
|
||||
for row_index in range(len(config.CAFE_INVITE_ROW_Y)):
|
||||
result = _try_invite_row(driver, config, row_index)
|
||||
result, name = _try_invite_row(driver, config, row_index)
|
||||
if result == "invited":
|
||||
print(f"[cafe] invited row {row_index}")
|
||||
print(f"[cafe] invited {name or f'row {row_index}'} (row {row_index})")
|
||||
invited = True
|
||||
break
|
||||
if result == "no_dialog":
|
||||
@ -423,8 +463,8 @@ def run(driver, config):
|
||||
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: inviting a student if available (highest affection)")
|
||||
_invite_student(driver, config, prefer_highest=True)
|
||||
|
||||
print("[cafe] room 1: farming affection")
|
||||
_pat_room(driver, config)
|
||||
@ -436,8 +476,8 @@ def run(driver, config):
|
||||
driver.wait(1.5)
|
||||
return
|
||||
|
||||
print("[cafe] room 2: inviting a student if available")
|
||||
_invite_student(driver, config)
|
||||
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)
|
||||
|
||||
@ -34,6 +34,24 @@ CLOSE_WAIT_ITERATIONS = 15
|
||||
CLOSE_WAIT_INTERVAL = 1
|
||||
|
||||
|
||||
def _alert_if_ap_left_unspent(driver, config):
|
||||
"""Print a distinctly-tagged, greppable log line if AP is still above
|
||||
config.EXIT_AP_ALERT_THRESHOLD right as the game is about to close --
|
||||
ba_cron_run.sh greps its own captured run output for the "AP_ALERT:"
|
||||
tag and fires an extra Discord alert on it (see plan.md Phase 30).
|
||||
Alert-only: never blocks or delays the exit itself.
|
||||
|
||||
Must run while still confirmed on true home (navigation.current_ap's
|
||||
OCR rect is only meaningful there, same precondition already documented
|
||||
on that function). An unreadable OCR result (None) is skipped silently,
|
||||
matching this project's "don't guess on unreadable OCR" convention
|
||||
rather than alerting on a possibly-wrong value.
|
||||
"""
|
||||
ap = navigation.current_ap(driver)
|
||||
if ap is not None and ap > config.EXIT_AP_ALERT_THRESHOLD:
|
||||
print(f"[exit_game] AP_ALERT: exiting with {ap} AP unspent (threshold {config.EXIT_AP_ALERT_THRESHOLD})")
|
||||
|
||||
|
||||
def _raise_exit_dialog(driver, config):
|
||||
for attempt in range(1, CONFIRM_RETRIES + 1):
|
||||
if attempt == CONFIRM_RETRIES and driver.window_exists():
|
||||
@ -64,6 +82,8 @@ def run(driver, config):
|
||||
print("[exit_game] could not confirm the true home screen -- aborting without pressing Escape")
|
||||
return
|
||||
|
||||
_alert_if_ap_left_unspent(driver, config)
|
||||
|
||||
if not _raise_exit_dialog(driver, config):
|
||||
print("[exit_game] could not confirm the exit-confirmation dialog opened -- aborting without pressing Enter")
|
||||
return
|
||||
|
||||
@ -470,10 +470,22 @@ def _wait_for_home(driver, config):
|
||||
|
||||
|
||||
def _wait_for_window(driver, config):
|
||||
# A window that satisfies the check below can still flicker away again
|
||||
# during game/Proton startup before it settles into the real, persistent
|
||||
# window -- confirmed live (2026-08-07, q4h cron traceback): the old
|
||||
# version returned True right after ONE window_exists() hit plus a fixed
|
||||
# 3s wait, but the window had disappeared again by the time the caller's
|
||||
# very next line (driver.focus_game()) ran its own independent check,
|
||||
# raising an uncaught RuntimeError and crashing the whole run. Re-check
|
||||
# window_exists() again after the stabilization wait, and keep polling
|
||||
# instead of trusting a stale True, so callers only ever get True for a
|
||||
# window confirmed present twice a few seconds apart.
|
||||
for _ in range(config.LOGIN_RELAUNCH_WAIT_ATTEMPTS):
|
||||
if driver.window_exists():
|
||||
driver.wait(3)
|
||||
return True
|
||||
if driver.window_exists():
|
||||
return True
|
||||
continue
|
||||
driver.wait(2)
|
||||
return False
|
||||
|
||||
@ -489,6 +501,13 @@ def _recover(driver, config):
|
||||
if not _wait_for_window(driver, config):
|
||||
print("[login] warning: game window did not reappear after relaunch")
|
||||
return False
|
||||
if not driver.window_exists():
|
||||
# Same flicker race as run() below, checked again right before the
|
||||
# call that would otherwise crash -- matches the established
|
||||
# window_exists()-then-focus_game() convention in navigation.py's
|
||||
# return_to_home/click_back.
|
||||
print("[login] warning: game window disappeared again right before focus -- giving up")
|
||||
return False
|
||||
driver.focus_game()
|
||||
return True
|
||||
|
||||
@ -500,6 +519,9 @@ def run(driver, config):
|
||||
if not _wait_for_window(driver, config):
|
||||
print("[login] warning: game window never appeared after launch -- giving up")
|
||||
return
|
||||
if not driver.window_exists():
|
||||
print("[login] warning: game window disappeared again right before focus -- giving up")
|
||||
return
|
||||
driver.focus_game()
|
||||
|
||||
for attempt in range(1, config.LOGIN_MAX_RELAUNCHES + 1):
|
||||
|
||||
@ -33,6 +33,17 @@
|
||||
# would just be noise. A missing, incomplete, or malformed .env silently
|
||||
# disables alerting only -- it can never affect the wrapped ba_dailies.sh
|
||||
# run or this script's own OK/FAILED/SKIPPED logging.
|
||||
#
|
||||
# Separately (see plan.md Phase 30): if the exit_game task ends the run
|
||||
# with AP still above config.EXIT_AP_ALERT_THRESHOLD, it prints a
|
||||
# distinctly-tagged "AP_ALERT:" line rather than posting to Discord itself
|
||||
# (Python has no .env/alert-bridge access by design -- see .env.example).
|
||||
# This script greps only the lines this run itself appended to LOG_FILE
|
||||
# (tracked via a before/after line count, not $(...) capture or `>()`
|
||||
# process substitution, so the log keeps streaming live and neither of
|
||||
# CLAUDE.md's Bash-safety triggers for those forms applies) and fires an
|
||||
# extra `error`-level alert (pings you) independent of the run's own
|
||||
# OK/FAILED outcome.
|
||||
set -uo pipefail
|
||||
|
||||
# Resolves its own directory (same pattern ba_dailies.sh itself already
|
||||
@ -90,6 +101,9 @@ alert() {
|
||||
start_epoch=$(date +%s)
|
||||
alert info "Starting $PRESET"
|
||||
|
||||
start_line=$(wc -l < "$LOG_FILE" 2>/dev/null)
|
||||
start_line="${start_line:-0}"
|
||||
|
||||
"$SCRIPT_DIR/ba_dailies.sh" "$PRESET"
|
||||
status=$?
|
||||
elapsed=$(( $(date +%s) - start_epoch ))
|
||||
@ -104,4 +118,9 @@ alert() {
|
||||
echo "=== $(date -Iseconds) finished $PRESET FAILED (exit $status) ==="
|
||||
alert error "$PRESET FAILED (exit $status) after $duration"
|
||||
fi
|
||||
|
||||
ap_alert_line=$(tail -n +"$((start_line + 1))" "$LOG_FILE" 2>/dev/null | grep -m1 "AP_ALERT:")
|
||||
if [ -n "$ap_alert_line" ]; then
|
||||
alert error "$PRESET: $ap_alert_line"
|
||||
fi
|
||||
} >> "$LOG_FILE" 2>&1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user