From bb0215cb0411fea2adaff4eb995524cff455a841 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Wed, 15 Jul 2026 14:09:55 +0900 Subject: [PATCH] fix(cafe): resolve invite ticket cooldown handling and improve rank-up cutscene detection --- ba_auto/config.py | 15 +++--- ba_auto/driver.py | 20 ++++++++ ba_auto/navigation.py | 30 ++++++++++++ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/cafe.py | 73 +++++++++++++++++++++++++++--- 5 files changed, 127 insertions(+), 13 deletions(-) diff --git a/ba_auto/config.py b/ba_auto/config.py index 1b496ef..3d96c5b 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -32,12 +32,15 @@ CAFE_INCOME = (1780, 1105) CAFE_MAX_CLICKS_PER_ROOM = 15 CAFE_SPARKLE_TEMPLATE = os.path.join(ASSET_DIR, "cafe_sparkle.png") # A pat that crosses an affection-rank threshold shows a full-screen "絆ラン -# クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all -- -# confirmed against screenshots/cafe/student/01-02: navigation.is_on_subscreen's -# header probe reads (183,220,240) there (r<200, fails) vs (248,249,250) on -# the normal cafe screen (r>200, passes), so the existing header-brightness -# check already tells the two apart. Bounds how many Enter presses -# _dismiss_rank_up_if_shown will try before giving up. +# クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all. +# Originally believed navigation.is_on_subscreen's single-pixel header probe +# reliably told this apart from the real cafe screen (confirmed against +# screenshots/cafe/student/01-02 at the time), but a real 2026-07-15 run hit +# a character whose cutscene art happened to read bright at that exact +# pixel, misreading the cutscene as already cleared -- see cafe.py's +# _dismiss_rank_up_if_shown and navigation.is_header_bar_visible for the +# fix (many header-row points must all read bright, not just one). Bounds +# how many Enter presses _dismiss_rank_up_if_shown will try before giving up. CAFE_RANK_UP_DISMISS_RETRIES = 5 # Horizontal camera panning before farming, per explicit user direction diff --git a/ba_auto/driver.py b/ba_auto/driver.py index d8eb6bd..7707b7c 100644 --- a/ba_auto/driver.py +++ b/ba_auto/driver.py @@ -105,5 +105,25 @@ def color_at(x, y): return int(r), int(g), int(b) +def colors_at(points): + """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 + full-screen captures) for one logical check. Also more correct than + looping color_at(): all points come from the exact same frame rather + than points sampled sequentially across several hundred ms of separate + captures, which could straddle a screen transition. + + Returns a list of (r, g, b) tuples in the same order as `points`. + """ + screenshot(PROBE_SHOT_PATH) + image = cv2.imread(PROBE_SHOT_PATH) + colors = [] + for x, y in points: + b, g, r = image[y, x] + colors.append((int(r), int(g), int(b))) + return colors + + def wait(seconds): time.sleep(seconds) diff --git a/ba_auto/navigation.py b/ba_auto/navigation.py index 0e37e46..c5f2b7b 100644 --- a/ba_auto/navigation.py +++ b/ba_auto/navigation.py @@ -28,6 +28,36 @@ def is_modal_open(driver): return r < MODAL_DIM_MAX_CHANNEL and g < MODAL_DIM_MAX_CHANNEL and b < MODAL_DIM_MAX_CHANNEL +# Every confirmed subscreen (mailbox/cafe/shop/lesson/event) renders a +# uniform light header bar spanning nearly the full screen width at this y -- +# is_on_subscreen only samples one x on that row (SUBSCREEN_HEADER_PROBE), +# which is cheap and has been fine for ordinary subscreen-vs-home checks, but +# was found live (2026-07-15, cafe.py's rank-up dismiss loop) to misread a +# full-screen "絆ランクアップ!" cutscene as "already back on subscreen" for +# some characters -- the single x=500 sample happened to land on a bright +# patch of that character's own art/background, not the real header. A real +# header bar is flat and uniform across its whole width; a photo-real +# cutscene composition (hair, uniform, the dark rank-up banner itself) is +# very unlikely to coincidentally read bright at MANY spread-out x offsets on +# the same row simultaneously. Used where that specific ambiguity matters +# (so far only cafe.py's rank-up dismiss) rather than swapped in for +# is_on_subscreen everywhere, to avoid changing already-working behavior at +# every other call site. +HEADER_ROW_Y = 10 +HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700) + + +def is_header_bar_visible(driver): + # 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). + points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS] + for r, g, b in driver.colors_at(points): + if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL): + return False + return True + + def _not_home(driver): # "Home" means neither on a subscreen NOR under an open modal. Checking # only is_on_subscreen was found live (2026-07-11, ba_daily.py's diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 68548e3..3d4aae4 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -5,7 +5,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Local feature | Reference file | Reference functions/classes | Local file | Backend replacements | Status | |---|---|---|---|---|---| | Mailbox | `module/mail.py` | `to_mail`, `implement` | `ba_auto/tasks/mailbox.py` | tap/click via xdotool, screenshot via scrot, `color.rgb_in_range` → `driver.color_at` pixel-probe check | Migrated: real Python, state-verified via color probe (no legacy bridge) | -| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — 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 (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. | +| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — 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 (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. | | Stamina/AP | `module/collect_daily_free_power.py`, `module/collect_daily_task_power.py` | `to_tasks`/`implement` (task-power, ported); `to_purchase_pyroxenes_menu` (free-power, not ported) | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Partially migrated: Mission-panel claim done (see `plan.md` Phase 8). Daily Free Power (real-money purchase menu) deliberately not automated | | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. | diff --git a/ba_auto/tasks/cafe.py b/ba_auto/tasks/cafe.py index be8c8a4..a8b7308 100644 --- a/ba_auto/tasks/cafe.py +++ b/ba_auto/tasks/cafe.py @@ -36,8 +36,34 @@ 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 via SWEEP_CONFIRM_BUTTON and returns False (skip invite this room) -instead of proceeding. +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). """ from ba_auto import detector, navigation @@ -81,12 +107,25 @@ def _dismiss_rank_up_if_shown(driver, config): # 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_on_subscreen(driver): + if navigation.is_header_bar_visible(driver): return True driver.keypress("Return") driver.wait(1.5) - return navigation.is_on_subscreen(driver) + return navigation.is_header_bar_visible(driver) def _pat_current_view(driver, config): @@ -201,8 +240,30 @@ def _open_invite_list(driver, config): 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") - driver.click(*config.SWEEP_CONFIRM_BUTTON) - driver.wait(1) + # 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