diff --git a/ba_auto/config.py b/ba_auto/config.py index f76d031..c15748b 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -126,7 +126,7 @@ CAFE_RANK_UP_DISMISS_RETRIES = 5 CAFE_PAN_DRAG_Y = 600 CAFE_PAN_RIGHT_X = 1500 CAFE_PAN_LEFT_X = 400 -CAFE_PAN_DRAG_REPEATS = 3 +CAFE_PAN_DRAG_REPEATS = 2 CAFE_PAN_DRAG_DURATION = 0.8 # Cafe student invitation (招待券, module/cafe_reward.py's invite_girl/ diff --git a/ba_auto/detector.py b/ba_auto/detector.py index d4e3877..a66b5d6 100644 --- a/ba_auto/detector.py +++ b/ba_auto/detector.py @@ -60,9 +60,13 @@ def _masked_template(template): return cv2.merge([mask_plane, mask_plane, mask_plane]) -def find_cafe_sparkle(): +def find_cafe_sparkle(image=None): + """`image` optionally supplies an already-decoded frame instead of + taking a fresh screenshot -- for cafe.py's per-iteration capture, shared + with that same iteration's navigation.is_header_bar_visible rank-up + check (see cafe.py's _pat_current_view).""" template_full = cv2.imread(config.CAFE_SPARKLE_TEMPLATE) - img = driver.read_screenshot(SPARKLE_SHOT_PATH) + img = image if image is not None else driver.read_screenshot(SPARKLE_SHOT_PATH) th0, tw0 = template_full.shape[:2] best = None diff --git a/ba_auto/driver.py b/ba_auto/driver.py index 2a50142..df51aee 100644 --- a/ba_auto/driver.py +++ b/ba_auto/driver.py @@ -133,7 +133,7 @@ def color_at(x, y): return int(r), int(g), int(b) -def colors_at(points): +def colors_at(points, image=None): """Sample multiple (x, y) points from a single screenshot, instead of one scrot capture per point -- added for navigation.is_header_bar_visible's multi-point header check, which otherwise called color_at() 8 times (8 @@ -142,12 +142,17 @@ def colors_at(points): than points sampled sequentially across several hundred ms of separate captures, which could straddle a screen transition. + `image` optionally supplies an already-decoded frame instead of taking a + fresh screenshot -- for callers reading several independent things (e.g. + cafe.py's rank-up header check + sparkle template match) off one shared + capture rather than one apiece. + Returns a list of (r, g, b) tuples in the same order as `points`. """ - image = read_screenshot(PROBE_SHOT_PATH) + img = image if image is not None else read_screenshot(PROBE_SHOT_PATH) colors = [] for x, y in points: - b, g, r = image[y, x] + b, g, r = img[y, x] colors.append((int(r), int(g), int(b))) return colors diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index 2364599..797701c 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -84,12 +84,16 @@ HEADER_ROW_Y = 10 HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700) -def is_header_bar_visible(driver): +def is_header_bar_visible(driver, image=None): # One screenshot for all 8 points (driver.colors_at) rather than 8 # separate color_at() calls -- cheaper, and atomic (every point comes # from the same frame instead of drifting across ~8 sequential captures). + # + # `image` optionally supplies an already-decoded frame (e.g. cafe.py's + # per-iteration capture, shared with that same iteration's + # find_cafe_sparkle call) instead of taking a fresh screenshot here. points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS] - for r, g, b in driver.colors_at(points): + for r, g, b in driver.colors_at(points, image=image): if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL): return False return True diff --git a/ba_auto/tasks/cafe.py b/ba_auto/tasks/cafe.py index b4e7153..05f3197 100644 --- a/ba_auto/tasks/cafe.py +++ b/ba_auto/tasks/cafe.py @@ -126,7 +126,7 @@ def _enter_room(driver, coords): return False -def _dismiss_rank_up_if_shown(driver, config): +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, @@ -147,9 +147,14 @@ def _dismiss_rank_up_if_shown(driver, config): # 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): + 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) @@ -179,12 +184,22 @@ def _pat_current_view(driver, config): # 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): - if not _dismiss_rank_up_if_shown(driver, config): + 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() + match = detector.find_cafe_sparkle(image=image) if match is None: driver.wait(1) continue @@ -195,6 +210,8 @@ def _pat_current_view(driver, config): # 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 diff --git a/plan.md b/plan.md index 61308a2..af93fa2 100644 --- a/plan.md +++ b/plan.md @@ -78,15 +78,23 @@ Prompted by a request to review real cron run logs (`scratchpad/ba_logs/{daily,q | daily #1 (03:30) | 15m32s | | | daily #2 (04:30) | **27m20s** | full run, 7 lesson tickets spent | -### Finding 1 (top priority, not yet implemented): `lesson.py` re-screenshots per cell instead of per region +### Finding 1 (implemented 2026-08-14): `lesson.py` re-screenshots per cell instead of per region In the 27m20s `daily` run, `lesson` alone took 9m50s (36% of the run), and ~7 minutes of that is the "scanning all regions" phase — 12 regions × ~30-38s each — that runs *before any ticket is spent*, just to build the priority queue. Root cause, confirmed by reading the code: `_scan_open_grid_cells` (`ba_auto/tasks/lesson.py:196`) loops 3 rows × 3 cols × 3 slots = 27 cells per region, and `_read_slot_affection` (`lesson.py:185`) calls both `detector.region_contains_color` (checkmark check) and, if not done, `detector.read_int_on_heart_badge` (OCR). Both independently call `driver.read_screenshot()`, and `read_screenshot()` fires a **brand-new `scrot` capture every call** (`ba_auto/driver.py:97-127`) — there's no reuse of a prior frame. That means a single region scan can trigger up to ~50 fresh `scrot` processes against a screen that hasn't changed at all between them (no clicks happen mid-scan, per `_scan_all_regions`'s own docstring: "a pure read, spends no tickets"). -**Proposed fix:** take one screenshot per region (or even once for the whole scan pass, if nothing changes on-screen until a click happens), decode it once, and pass the decoded image into the per-cell checkmark/OCR reads instead of each one re-capturing. This is a pure efficiency fix — the detection logic itself (color masking thresholds, heart-badge OCR contamination handling) doesn't change at all, so it doesn't touch anything CLAUDE.md's OCR policy cares about. Expected to cut the scan phase from ~7 minutes to well under a minute. +**Fix:** `detector.py` gained an optional `image=` parameter on `region_contains_color`/`read_int_on_heart_badge` (default `None`, so every other caller — `shop_utils.py`, `story_sweep.py`, `story_sweep_hard.py`, `cafe.py` — is unaffected) plus a `capture_screen()` helper. `lesson.py`'s `_scan_open_grid_cells` now takes one screenshot per region and threads it through all 27 checkmark/OCR reads instead of each one re-capturing. Pure efficiency fix — the detection logic itself (color masking thresholds, heart-badge OCR contamination handling) is untouched. -**Scope note:** `_run_queue`'s actual ticket-spending clicks (`lesson.py:306`) happen between scans, so only the *scan* phase's screenshots are provably safe to batch this way — anything read after a click still needs a fresh capture. +**Scope respected:** `_run_queue`'s actual ticket-spending clicks (`lesson.py:306`+) still take a fresh screenshot per check, exactly as before — batching only applies to the pure-read scan phase. + +**Live-verified 2026-08-21** against a full week of real `daily` cron runs (2026-08-15 through 2026-08-21, 14 runs, all exit 0, zero `[lesson]` warnings/errors). Full 12-region scan phase now consistently takes ~3m27s, down from the ~7m2s baseline — a ~51% cut, exactly from removing the redundant per-cell `scrot` calls (per-region time dropped from ~30-38s to ~17-18s). + +### Finding 1b (implemented 2026-08-14): stop scanning once triples alone cover the ticket count + +Added alongside Finding 1: `_scan_all_regions` now takes `tickets` and tracks a running triple count, breaking out of the region loop once `triple_count >= tickets`. This is provably behavior-preserving, not just a heuristic: `_build_priority_queue` never reorders within a tier (triples/doubles stay in scan order) and `_run_queue` stops the instant tickets hit 0, so once enough triples are found, any cell in an unscanned region could only ever land after the ticket budget is already exhausted — `_run_queue` would never reach it either way. The queue actually executed is identical to a full scan's; only the number of regions looked at changes. When tickets exceed available triples, the condition never fires and behavior is unchanged (full scan, as today). One cosmetic side effect: the "N triple(s), M double(s), K single(s)" summary log line under-reports M/K when this fires, since those regions were never examined — expected, not a bug. + +**Live-verified 2026-08-21**, same week of runs: fired correctly on 2 of 7 ticket-available days (2026-08-18: 7 triples found by region 9/12, skipped the remaining 3; 2026-08-21: 7 triples by region 10/12, skipped 2), and correctly stayed a full 12-region scan on the other 5 days where triples never reached the ticket count. Combined with Finding 1, total `lesson` task duration (start to `Done.`) ranged 5m6s-6m41s across the week, down from the 9m50s baseline. ### Finding 2 (secondary, informational — do not touch without care): `event_sweep` is called 2-4x per run by design