ba-auto-daily/ba_auto/tasks/event_sweep.py
Nik Afiq 6cdce8ccdd Fix handling of transient misreads and improve event page checks
- Updated `_scan_stage_rows_once` to return full labels list instead of a boolean, allowing for better validation of stage presence.
- Enhanced `_find_stage_row` to require consistent label readings across consecutive scans to avoid false positives from transient misreads.
- Corrected `EVENT_FINISHED_TEXT_RECT` coordinates to ensure accurate OCR readings for finished events.
- Modified `run()`'s retry logic to treat `stage_not_found` similarly to `wrong_page`, enabling retries for potentially misidentified pages.
- Confirmed fixes through live testing, ensuring robust navigation and accurate event identification.
2026-07-29 23:10:09 +09:00

787 lines
42 KiB
Python

"""Event AP sweep. Reference: baas-reference/module/sweep_activity.py and
module/activities/activity_utils.py's activity_sweep/start_sweep.
Ported per explicit user direction (2026-07-10): the currently-running event
("鉄道爆走事件") exposes 12 stages via a 任務情報 (task info) modal that is
structurally identical to story_sweep.py's own stage modal (MIN/-/+/MAX
count stepper, the same AP-usage confirm dialog, the same 掃討完了 SKIP/OK
result screen) -- see config.py's "Event sweep" section for the full live
calibration writeup. Rather than porting the reference's config-string
sweep-list parsing (arbitrary stage lists with per-stage float/fraction
counts), this sweeps exactly one stage per run, chosen from a fixed 9-12
sub-range via the same date-ordinal-modulo rotation story_sweep.py already
uses for its own daily-rotating target.
Key differences from story_sweep.py, all confirmed live during calibration:
- No region concept/navigation -- one flat stage list, reached via the home
screen's event badge -> Quest tab, not story_sweep's Work-hub card ->
region browser.
- The stage list only ever needs its bottom scroll extreme, which always
reveals stages 9-12 (this event's last 5 stages) regardless of starting
scroll position -- no separate top/bottom row-position sets needed.
- Row height is constant regardless of 1-line vs 2-line title wrapping,
unlike story_sweep's stage list.
- The stage modal has ONE fixed layout (confirmed against both stage 09 and
12), no tabbed-vs-plain variant to account for.
- The stage modal DOES close on Escape, unlike story_sweep's (X-button-only).
- Both stages tested were already 3-starred; the reference's SSS-availability
gate for never-cleared stages was never actually exercised (see config.py).
Live-testing after initial calibration found the home screen's event badge
(config.EVENT_BADGE_ICON) is itself a rotating carousel, not a stable
single-event slot as calibration happened to suggest: it cycles between the
current event's countdown AND other notices (e.g. an already-finished
event's remaining reward-claim-period reminder), so a single click can land
on a stale/wrong event page instead of the current one. _find_stage_row now
distinguishes "wrong page entirely" (no stage-row numbers OCR at all) from
"right page, this specific stage just isn't there" (some numbers found, just
not the target) -- the former retries via navigation.return_to_home + a
re-click of the badge, hoping the carousel has moved on by the next attempt;
the latter is reported and left alone, since retrying won't fix a genuinely
different stage list.
A second live run (after the above fix) correctly recovered from a wrong
first attempt and correctly OCR'd the target stage's row number, but then
failed to open that stage's info modal: the row-enter click was a single
click-then-check with no retry, unlike every other click-then-confirm step
in this module. _open_stage_modal now retries it the same way, matching
CLAUDE.md's own documented history of the mailbox/cafe icons missing their
first click and working on retry.
A third live run surfaced two more instances of the same underlying
problem class:
1. _find_stage_row's "wrong page" detection (all 5 rows OCR empty) fired
as a FALSE POSITIVE twice in a row on a run that was actually on the
right page the whole time -- the Quest tab's stage list evidently
hadn't finished rendering yet on the first OCR pass right after
navigating there fresh, and 1 second wasn't always enough settle time.
Burning a full return-home-and-re-navigate cycle (the "wrong page"
recovery path) on a pure rendering race wastes retry budget that should
be reserved for an actually-wrong page. _find_stage_row now does a
cheap in-place rescan (re-read the same rows, no re-scroll/re-navigate)
a couple of times before concluding "no valid rows at all."
2. Once past that, the MAX-count-raise succeeded (on its own internal
retry) but the following 掃討開始 (start sweep) click was, again, a bare
single click with no retry -- the click missed, no confirm dialog ever
appeared, and the whole run aborted right at the last step before
actually spending AP. _click_sweep_start_and_verify now wraps it the
same way as every other click in this file.
A fourth run (this time self-driven live-testing, not a user report) found
the wrong-page problem again, but WORSE: all 3 outer attempts landed on the
wrong page, back to back, with no false-positive settling issue this time --
the badge carousel was just genuinely sitting on the wrong item (a finished
event's reward-claim reminder) for the entire run, confirmed by screenshot.
Investigating live found the carousel's own small pagination dots (below
the badge thumbnail) are directly clickable and immediately switch pages,
rather than needing to wait for its auto-rotate timer (which is far slower
than this task's retry window -- confirmed still showing the wrong item
20-30+ seconds later). _open_event_screen now clicks a specific dot
(config.EVENT_BADGE_DOT_X) BEFORE each badge-open attempt, cycling through
dot positions across run()'s outer retry loop, instead of repeatedly
clicking the ambiguous badge itself and hoping the timer has moved on.
Even with the dot fix, a fifth run (still self-driven) STILL read all 5
rows as None on every attempt. Live diagnosis (standalone probe scripts
importing this project's own driver/detector code directly, run via SSH)
found the dot-click + badge-open navigation was actually landing correctly
every time -- the remaining problem was pure timing: after a long-idle
cold start, the Quest tab's stage list can take FAR longer to actually
populate than assumed (empirically, up to ~20 seconds from a genuinely
cold start, vs. the ~3.5s total budget STAGE_ROW_SCAN_ATTEMPTS/
_RETRY_WAIT gave it), most likely a server round-trip the client only pays
on the first open in a session. A polling probe confirmed reads stabilize
and stay stable for a full minute-plus once they succeed -- this was never
a flaky/intermittent render glitch, just a budget that was too short for a
cold start specifically. STAGE_ROW_SCAN_ATTEMPTS/_RETRY_WAIT were widened
to a ~20s total budget to match. The immediate next live run (same
session) went all the way through: found stage 12's row, opened the
modal, raised the count to MAX, clicked 掃討開始, confirmed the AP-usage
dialog, and a REAL 10x sweep executed -- 200 AP spent (206 -> 6, matching
the calibrated MAX count exactly at that AP level) and credits increased,
confirmed by screenshot. The one remaining wrinkle: _watch_sweep_result's
own polling budget (POST_SWEEP_DISMISS_ROUNDS, originally 6 iterations x
1.5s = 9s) was too short for a 10x bulk sweep's longer reward-reveal
sequence, so the outcome was logged as "unrecognized_state" instead of
"swept" even though the sweep itself succeeded and _close_stage_modal's
fallback safely recovered the screen back to home afterward. Widened to
14 iterations to match the same cold-start-budget lesson, though this
specific fix hasn't been re-confirmed live (that day's AP was fully spent
by the successful sweep above, leaving none for a further live test).
A sixth run, a day later (fresh AP), went right back to the original
symptom: all 5 rows None across the full 9-scan/~20s budget, on both
outer attempts shown in the user's log before it was interrupted. Per the
user's own diagnosis -- check for the finished event's own "イベント期間が
終了しました" text directly, rather than inferring "wrong page" from empty
rows alone -- Japanese OCR support (tesseract-ocr-jpn) was installed
(previously only "eng" was available; installing needs interactive sudo,
which the user did directly).
Live investigation to pin down the exact text region initially hit a wall
-- repeated attempts to reproduce a wrong/finished event page (clicking
every known badge-carousel dot position, the bottom-left banner, the
event-story replay archive) kept landing back on the CURRENT correct
event instead, suggesting run #4's original "carousel shows the wrong
item" diagnosis might not be reliably reproducible on demand. Eventually
reproduced it anyway (clicking the badge while it happened to be
displaying "嵐過天晴"'s reward-claim-period notice, same as run #4) and
used it to calibrate for real: EVENT_FINISHED_TEXT_RECT's OCR read the
exact phrase "イベント期間が終了しました。" verbatim via lang="jpn", and a
follow-up check on the correct event's own page read unrelated stage-list
text with no false-positive "終了" match -- both directions live-confirmed,
not guessed. _is_finished_event_page is used as an early-exit,
authoritative-when-positive check inside _find_stage_row's scan loop: a
positive match ends the wait immediately as a confirmed wrong page; a
negative match does NOT prove the page is right (a different finished
event's layout might differ), so patient rescanning still continues
regardless either way up to the existing budget. If that budget is ever
fully exhausted with no rows and no confirmed finished-page text, a debug
screenshot is saved to scratchpad/ (event_sweep_no_rows_debug.png) for
further diagnosis -- that residual "inconclusive" case is still treated
as "wrong_page" for recovery purposes (return home, retry with a
different badge-carousel dot), since sitting stuck indefinitely isn't
better than an unnecessary retry.
The very next live run with the finished-text check deployed confirmed it
works exactly as designed -- every one of the 3 outer attempts correctly
and immediately identified "finished-event page text detected" instead of
wasting the full ~20s rescan budget on each, a big win for diagnostic
clarity. But it also revealed the fix's real limit: all 3 attempts landed
on the SAME wrong page. Manual live investigation right after found the
badge carousel's dot-click behavior is NOT a reliable way to force a
specific page after all -- clicking a dot sometimes visibly switched the
badge's content (as run #4/#6 first found) and sometimes did nothing at
all (confirmed back-to-back on the same badge state), while simply
waiting was independently observed to eventually cycle the badge back to
the correct event on its own. This points to a genuine time-based
auto-rotate timer as the real mechanism, with dot-clicking being at best
an unreliable nudge on top of it, not a deterministic override. The Work
hub (お仕事, a stable non-carousel entry point already used by story_sweep/
arena) was also checked as a possible alternative and does NOT have a
dedicated card for this event, so the badge remains the only viable entry
point. Given the timer is the real mechanism, WRONG_PAGE_RETRIES/_WAIT
were widened (3 attempts x 3s -> 6 attempts x 12s, ~72s total) to give the
natural rotation a real chance to land on the correct item within the
retry window, rather than relying on a fast-but-unreliable dot-click to
force it. Dot-clicking is kept as a harmless best-effort nudge alongside
the longer wait, not removed, since it did visibly work at least twice.
Stage 08/09 OCR misread (2026-07-11, previously flagged in plan.md as a
known-but-non-blocking gap, then hit for real once the daily rotation
picked stage 9): row @366 ("08") read as "2", row @538 ("09") read as
empty/None, on every psm mode tried with plain detector.read_int, even
though the crop and its thresholded/upscaled version both looked perfectly
clean by eye (scratchpad/probe_event_stage_ocr.py, probe_ocr_psm_sweep.py)
-- confirmed live NOT a navigation/timing bug this time, since rows
710/883/1055 ("10"/"11"/"12") read correctly in the same run. Root cause,
isolated via scratchpad/probe_ocr_fix_08_09.py: tesseract's segmentation
struggles with this specific tight edge-to-edge crop (no surrounding
whitespace margin) for a leading-zero digit pair specifically -- adding a
plain white border around the upscaled crop before OCR fixed both "08"
and "09" to their exact correct values at every psm mode tried, without
affecting the already-working "10". Ported as detector.read_int_bordered,
now used for all EVENT_STAGE_ROW_Y reads in _scan_stage_rows_once.
The same live run also confirmed the sweep itself succeeded end-to-end
(AP 233->14, credits +5,892, screenshot-confirmed safe return home) but
still logged "unrecognized_state" instead of "swept" -- the
_watch_sweep_result outcome-logging bug flagged after Phase 14 follow-up
#5 as "not yet re-verified live" was, it turns out, still broken, just for
a DIFFERENT reason than the POST_SWEEP_DISMISS_ROUNDS timing budget that
fix targeted. Root cause this time, isolated with zero AP cost via
scratchpad/probe_result_button_fp.py (checks _find_result_button against
the plain Quest list with no sweep in progress): the shared
SWEEP_RESULT_BUTTON_REGION (700-1300 x-range, borrowed directly from
story_sweep.py) reaches into this event's own Quest-list character-art
panel on the left side of the screen, which false-positive-matched
SWEEP_CONFIRM_CYAN even with no result dialog showing at all -- confirmed
on both a wrong/finished event page and, more importantly, the actual
correct current event's own plain list. This meant _watch_sweep_result's
reaction kept "finding" a result button and clicking it after the real
one had already been fully dismissed, so the "modal closed, no result
button" end condition could never match. Fixed with a new, event_sweep-
only EVENT_SWEEP_RESULT_BUTTON_REGION (x narrowed to 1000-1300, excluding
the character-art panel while still comfortably covering the real
buttons' known x~1150 position) -- confirmed via the same saved
false-positive screenshot returning no match afterward. Deliberately NOT
changed in the shared SWEEP_RESULT_BUTTON_REGION story_sweep.py also uses,
per this file's own established pattern of giving event_sweep its own
constant rather than coupling the two tasks' config together whenever
their actual on-screen content differs (see EVENT_STAGE_MODAL_PROBE's
comment for the earlier instance of the same reasoning). Not yet
re-confirmed against a fresh real bulk sweep (that day's AP was reduced to
14/240 by the sweep that surfaced this, too low to force another MAX
sweep) -- the fix is validated against the actual recorded false-positive
screenshot and the same live code path, just not a full new live sweep.
The x-narrowing above turned out to not be the whole story either. A real
run (2026-07-13, user report: "the sweep went well, but I think it
overclick and closed the result page") again logged "unrecognized_state"
despite a real successful MAX sweep (rows 08-12 all read correctly, sweep
confirmed). Root cause this time: EVENT_SWEEP_RESULT_BUTTON_REGION's
y1=700 still overlapped EVENT_SWEEP_START_BUTTON's (1400,668) own real
cyan-pixel footprint (y 622-715, x 1152-1655) -- known precisely without
needing a fresh live measurement, since bounty.py's own investigation into
the identical bug class (its stage-info modal is confirmed pixel-identical
to this one, sharing this exact button position) already measured it
directly from a real screenshot. Once the real "掃討完了" result dialog is
dismissed and the flow lands back on the bare stage-info modal (this
function's own second `ends` condition), 掃討開始 becomes visible and
cyan again -- _find_result_button re-matched its corner and clicked it,
re-opening a fresh AP-usage-confirm dialog this loop had no way to
recognize as anything but "still a result button showing," exactly
matching the user's own diagnosis. Fixed two ways, both ported directly
from bounty.py's own resolution of the identical bug: (1)
EVENT_SWEEP_RESULT_BUTTON_REGION's y1 shifted from 700 to 730 (15px clear
of the button's measured 715 bottom edge), kept wide through y2=1050 to
still cover a possible SKIP-then-OK sequence's ~120px vertical spread,
since this event's own SKIP button was never individually pixel-measured;
(2) detector.find_color_centroid's min_pixels parameter (added for
bounty.py's own second contamination source -- sparse stray pixels in the
modal's own reward-icon artwork) applied here too via
EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS, carried over from
BOUNTY_RESULT_BUTTON_MIN_PIXELS by analogy rather than freshly measured
against this larger region. _watch_sweep_result also gained a third
`ends` condition on _is_ap_purchase_prompt (mirroring bounty.py's
_is_ticket_purchase_prompt fix) as a second, independent safety layer: if
a purchase prompt is ever reached here anyway, it's recognized and
cancelled explicitly by _sweep_target rather than left to the blind color
search. Confirmed live the very next real run: a real MAX sweep of stage
12 completed and correctly logged "result: swept" (not
"unrecognized_state") for the first time.
That same live run surfaced a separate, real bug in the wrong-page
recovery path, reported directly by the user along with the fix: "when
landed on wrong event page, it will click on the button(?) under the
event. The button does nothing. Fastest way is to click event right away
when get to home after returning, since ongoing event will always be on
top by default, then scrolled away automatically after few ms." This
overturns follow-up #5's own theory (the carousel's auto-rotate timer is
SLOW, so waiting longer would eventually land on the correct event) --
the true mechanism is the opposite: the current event is the carousel's
DEFAULT item immediately after the home screen is reached, and it
auto-rotates away again quickly, so the fix is to click the badge as fast
as possible after confirming home, not to wait for a slow timer to cycle
around. The carousel's own pagination dots (EVENT_BADGE_DOT_X/_Y,
originally added in follow-up #4 to "force" a specific page) don't
reliably do anything either, per the user's own report, and calling them
before every badge-click attempt was actively counterproductive -- extra
clicks and waits that only pushed the badge-click further past the
correct-by-default window. **Fix**: `_select_badge_page`/dot-clicking
removed entirely; `_open_event_screen` no longer takes a `dot_index` and
just clicks the badge directly; `run()`'s retry loop no longer waits
`WRONG_PAGE_RETRY_WAIT` (12s, now removed) between `navigation.
return_to_home` and the next `_open_event_screen` call -- it retries
immediately.
The report this fix is based on was the STILL-OLD dot-click/12s-wait
design (the same run that confirmed the `_watch_sweep_result` fix above):
it hit `wrong_page` on attempts 1-3 (all rows `None`, finished-event text
confirmed each time) and only succeeded on attempt 4, after 3 full 12s
waits -- the user's diagnosis of *why* it eventually worked (not because
the dots did anything, but because returning home and simply trying again
naturally re-lands on the correct default item) is what this fix acts on,
not a live test of the fix itself.
Confirmed live at zero AP cost immediately after deploying: 3 separate
trials of `navigation.return_to_home` -> `_open_event_screen` (no dot
click, no wait) -> checking `_is_finished_event_page`/reading the stage
rows, all 3 landed on the correct current event on the very first attempt
(`finished_page=False`, all 5 rows read their correct stage numbers,
target stage 12 found at row 1055) -- no `wrong_page` outcome at all
across all 3 trials. This confirms the navigation half of the fix
directly; the full sweep-and-result path wasn't re-exercised in this same
check (that needs spending real AP, deferred pending the user's own next
real run).
A real run (2026-07-29, direct user report with the exact log) surfaced a
new failure mode in a genuinely different part of the wrong-page handling:
"When it enter wrong page it will just wait then exit on false positive
instead of return home and retry to enter correct page." The log showed
exactly why: scan 1 read all 5 rows `None` (correctly treated as "still
settling"), but scan 2 read a single stray/partial digit ('7', matching
nothing real on this event) at just one row position while the other 4
were still `None`. The old `_find_stage_row` treated ANY non-`None` read
as proof the page was genuinely, fully rendered -- so it immediately
concluded "confirmed right page, stage 11 just isn't in it"
(`stage_not_found`) off that single transient misread. `stage_not_found`
was never in the retryable set in `run()`'s loop (only `wrong_page` was),
so the whole task exited immediately instead of returning home and
retrying -- the exact behavior reported.
By coincidence, live investigation into this (clicking the event badge at
the exact instant it displayed the OLD/finished event's promotional text,
which -- unlike every earlier attempt in this file's history -- actually
DID open that finished event's own page this time, confirming the timing
really is the deciding factor, not the dot-clicking this project already
gave up on) also surfaced a second, independent real bug: the finished-
event page's own confirmed real capture (user-supplied
scratchpad/event_event.png, and a fresh live re-capture used for
calibration) showed `EVENT_FINISHED_TEXT_RECT`'s OCR read returning pure
garbage with no "終了" match at all -- not merely clipped, almost entirely
background office-art below the real text line, which actually sits
higher on screen than the old rect assumed. This means the "authoritative"
finished-page check had been silently broken (always reading false-
negative) at least since whatever event succeeded the one it was last
calibrated against, quietly falling through to the same fragile
saw-any-valid-row heuristic that caused the bug above. Re-calibrated via
scratchpad/probe_event_finished_text.py (since deleted) against the live
page directly -- confirmed the corrected rect reads clean text containing
"終了".
Fixed three ways:
1. `_scan_stage_rows_once` now returns the full labels list, not just a
`saw_any_valid_row` bool. `_find_stage_row` no longer trusts a "some
rows read, target not among them" conclusion the first time it appears
-- it now requires the exact same labels to read back identically on
two consecutive scans before treating "genuinely not found" as settled,
the same stabilize-before-trusting fix already applied to the all-
`None` case. A single transient misread can no longer end the scan
early.
2. `config.EVENT_FINISHED_TEXT_RECT` corrected from (1030,600,1810,750) to
(1080,585,1760,635) -- see that constant's own comment for the before/
after OCR reads.
3. `run()`'s retry loop now treats `stage_not_found` the same as
`wrong_page` (return home, retry immediately) rather than as terminal --
this event's target range is always supposed to be in the visible
bottom rows of a genuinely correct, fully-rendered page, so failing to
find it there (post-stabilization) is much more likely to mean "wrong
page" than "this event really lacks it."
Not yet re-confirmed against a fresh real failure of the exact original
kind (the live investigation above spent its effort reproducing/fixing the
finished-text rect instead, since that page happened to be reachable at
the time) -- next real `stage_not_found`-triggering run should confirm the
stability fix directly.
"""
import datetime
import os
from ba_auto import detector, navigation
OPEN_RETRIES = 3
POST_SWEEP_DISMISS_ROUNDS = 14
MAX_BUTTON_RETRIES = 3
MODAL_CLOSE_RETRIES = 3
WRONG_PAGE_RETRIES = 6
STAGE_ENTER_RETRIES = 3
STAGE_ROW_SCAN_ATTEMPTS = 9
STAGE_ROW_SCAN_RETRY_WAIT = 2.5
SWEEP_START_RETRIES = 3
def _is_stage_modal_open(driver, config):
r, g, b = driver.color_at(*config.EVENT_STAGE_MODAL_PROBE)
return r < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL
def _color_in_range(rgb, rgb_range):
lo, hi = rgb_range
r, g, b = rgb
return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2]
def _is_sweep_usage_confirm(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN)
def _is_ap_purchase_prompt(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD)
def _find_result_button(driver, config):
return detector.find_color_centroid(
config.EVENT_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN,
min_pixels=config.EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS,
)
def _count_raised_above_one(driver, config):
# This modal's "-" stepper turns a saturated coral/orange once raised,
# a subtler shift than story_sweep's vivid-orange indicator -- told
# apart from its flat-grey default by color *spread* (max-min channel)
# rather than a fixed g/b ceiling. See config.py's EVENT_SWEEP_MINUS_
# BUTTON_PROBE comment.
r, g, b = driver.color_at(*config.EVENT_SWEEP_MINUS_BUTTON_PROBE)
return (max(r, g, b) - min(r, g, b)) > 40
def _open_event_screen(driver, config):
# Per explicit user direction (2026-07-13), superseding the dot-click
# approach: the carousel's pagination dots don't reliably do anything
# ("it will click on the button under the event. The button does
# nothing"), and the earlier "wait long enough for the slow auto-rotate
# timer to cycle back to the correct event" theory had it backwards --
# the current/ongoing event is the DEFAULT item shown the moment the
# home screen is reached, and it auto-rotates AWAY within a very short
# window afterward. The fix is to click the badge as immediately as
# possible after confirming home, not to wait -- see run()'s own retry
# loop, which no longer inserts a wait between navigation.return_to_home
# and the next _open_event_screen call for exactly this reason.
for attempt in range(1, OPEN_RETRIES + 1):
driver.click(*config.EVENT_BADGE_ICON)
driver.wait(2)
if navigation.is_on_subscreen(driver):
break
print(f"[event_sweep] event screen not detected after click (attempt {attempt}/{OPEN_RETRIES})")
else:
return False
driver.click(*config.EVENT_QUEST_TAB)
driver.wait(1)
return True
def _row_number_rect(config, row_y):
x1, x2 = config.EVENT_STAGE_NUMBER_OCR_X
top_pad, bottom_pad = config.EVENT_STAGE_NUMBER_OCR_Y_PAD
return (x1, row_y - top_pad, x2, row_y + bottom_pad)
def _scan_stage_rows_once(driver, config, stage):
labels = []
row_y_match = None
for row_y in config.EVENT_STAGE_ROW_Y:
label = detector.read_int_bordered(_row_number_rect(config, row_y))
print(f"[event_sweep] row @ {row_y}: read '{label}'")
labels.append(label)
if label == stage:
row_y_match = row_y
return row_y_match, labels
def _is_finished_event_page(driver, config):
# Direct, definitive check for a finished/stale event's own "イベント
#期間が終了しました。" (event period has ended) text, per explicit user
# request after repeated false "wrong page" loops -- see config.py's
# EVENT_FINISHED_TEXT_RECT comment for the full story, including that
# this rect is a best-effort estimate, not yet live-confirmed against a
# real capture of this exact text. Matches on the substring "終了"
# rather than the full phrase, since that's more tolerant of OCR noise
# while still being distinctive vocabulary within this screen -- the
# stage list's own text (stage names, "入場", star counts) never
# contains it.
text = detector.read_text(config.EVENT_FINISHED_TEXT_RECT, psm=6, lang="jpn")
return "終了" in text
def _find_stage_row(driver, config, stage):
# This event's target range (9-12) always sits within the last 5 rows,
# confirmed live regardless of starting scroll position -- so unlike
# story_sweep._find_stage_row, only the bottom extreme is ever checked.
#
# Also tracks whether ANY row OCR'd a real number at all. Originally,
# all-empty reads alone were treated as "wrong page" -- live testing
# found that produces false positives (a fresh navigation's list can
# still be settling/rendering for far longer than expected, up to
# ~20s+), and false negatives are cheap to avoid: an explicit check for
# the finished-event page's own "period ended" text (_is_finished_event_
# page) is the AUTHORITATIVE signal now. All-empty reads alone just
# mean "keep waiting patiently", not "wrong page" -- see module
# docstring for the full history of this getting fixed twice.
#
# A real run's log (2026-07-29, user report) surfaced a related false
# positive this all-empty handling didn't cover: scan 1 read all 5 rows
# None (correctly treated as "still settling"), but scan 2 read a single
# stray/partial digit ('7', not matching any real row on this event) at
# just ONE row position while the other 4 were still None -- one non-
# None read was enough to satisfy the old "saw ANY valid row -> trust
# it" check, so the loop immediately concluded "genuinely on the right
# page, target stage just isn't in it" (stage_not_found) off a single
# transient misread, instead of recognizing the list was still mid-
# render. Fixed the same way the all-empty case already was: don't
# trust a "some rows read, target not among them" conclusion the first
# time it appears either -- require the exact same set of labels to
# read back identically on two consecutive scans first. A genuinely
# rendered (even wrong) page's OCR reads are stable frame to frame; a
# mid-render one isn't, which is exactly the signal that already fixed
# the all-empty version of this same problem class.
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
previous_labels = None
for attempt in range(1, STAGE_ROW_SCAN_ATTEMPTS + 1):
row_y, labels = _scan_stage_rows_once(driver, config, stage)
if row_y is not None:
return row_y, True, False
saw_any_valid_row = any(label is not None for label in labels)
if saw_any_valid_row and labels == previous_labels:
return None, True, False
if _is_finished_event_page(driver, config):
print("[event_sweep] finished-event page text detected -- confirmed wrong page")
return None, False, True
if saw_any_valid_row:
print(f"[event_sweep] some rows read but not yet stable across scans (scan {attempt}/{STAGE_ROW_SCAN_ATTEMPTS}): {labels}")
else:
print(f"[event_sweep] no stage-row numbers recognized on scan {attempt}/{STAGE_ROW_SCAN_ATTEMPTS} -- screen may still be settling")
previous_labels = labels
if attempt < STAGE_ROW_SCAN_ATTEMPTS:
driver.wait(STAGE_ROW_SCAN_RETRY_WAIT)
# Exhausted the patience budget with no valid rows AND no confirmed
# finished-event text -- a genuinely inconclusive state. Save a debug
# screenshot so a future recurrence can actually be diagnosed/used to
# fix EVENT_FINISHED_TEXT_RECT's calibration, per CLAUDE.md's "write
# debug images to scratchpad/" convention.
debug_path = os.path.join(config.SCRATCHPAD_DIR, "event_sweep_no_rows_debug.png")
driver.screenshot(debug_path)
print(f"[event_sweep] gave up waiting for stage rows without confirming finished-event text either -- saved {debug_path}")
return None, False, False
def _click_max_and_verify(driver, config):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
driver.click(*config.EVENT_SWEEP_MAX_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[event_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _click_plus_and_verify(driver, config, count):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
for _ in range(count - 1):
driver.click(*config.EVENT_SWEEP_PLUS_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[event_sweep] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _set_sweep_count(driver, config, count):
if count == "max":
return _click_max_and_verify(driver, config)
return _click_plus_and_verify(driver, config, count)
def _close_stage_modal(driver, config):
# Confirmed live this modal DOES close on Escape (unlike story_sweep's,
# which needs its own X button) -- try Escape first, fall back to the X
# button if it somehow doesn't clear.
for _ in range(MODAL_CLOSE_RETRIES):
if not _is_stage_modal_open(driver, config):
return True
driver.keypress("Escape")
driver.wait(1)
if not _is_stage_modal_open(driver, config):
return True
driver.click(*config.EVENT_STAGE_MODAL_CLOSE_BUTTON)
driver.wait(1)
return not _is_stage_modal_open(driver, config)
def _watch_sweep_result(driver, config):
# Confirmed live twice now (both real 10x/11x bulk sweeps): after the
# last result-screen button (SKIP then a final OK) is clicked, the game
# can land all the way back on the underlying Quest LIST rather than
# the bare stage-info modal this was originally calibrated against
# (story_sweep.py's own equivalent screen always returns to its stage
# modal, which is why the "ends" check here originally required it) --
# both real sweeps succeeded (AP spent, credits gained, confirmed by
# screenshot) but this "ends" check never matched, exhausting the full
# POST_SWEEP_DISMISS_ROUNDS budget regardless of size and falling
# through to "unrecognized_state" even though the sweep genuinely
# completed. `clicked_any` gates the modal-closed branch on having
# clicked at least one result button first, so an immediate "no result
# button visible yet" read on the very first check (before the
# SKIP/OK sequence has even started) still can't be mistaken for
# "swept" -- only "modal gone after we've actually clicked through
# something" counts.
#
# A THIRD ends condition, added 2026-07-13 after a real user report
# ("the sweep went well, but I think it overclick and closed the
# result page"): EVENT_SWEEP_RESULT_BUTTON_REGION used to overlap
# EVENT_SWEEP_START_BUTTON's own real footprint (see that config
# constant's comment), so once the real result dialog was dismissed and
# the bare stage-info modal reappeared, _find_result_button could
# re-match 掃討開始 itself and click it -- re-opening a fresh AP-usage-
# confirm dialog this loop had no way to recognize as anything but
# "still a result button." The region is now fixed to exclude that
# button's footprint, but this explicit check (mirroring bounty.py's
# own identical `_is_ticket_purchase_prompt` fix for the same bug
# class) is a second, independent layer: if a purchase/insufficient-AP
# prompt is ever reached here anyway, for any reason, it's recognized
# and handled explicitly by the caller rather than left to a blind
# color search that could otherwise re-click into it.
clicked_any = {"value": False}
def click_result_button(d):
pos = _find_result_button(d, config)
if pos:
d.click(*pos)
clicked_any["value"] = True
d.wait(1.5)
ends = {
(lambda d, c: _is_ap_purchase_prompt(d, c)): "prompted_to_purchase",
(lambda d, c: _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
(lambda d, c: clicked_any["value"] and not _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
}
reactions = {
(lambda d, c: _find_result_button(d, c) is not None): click_result_button,
}
outcome = navigation.wait_for_state(
driver, config, reactions, ends,
max_iterations=POST_SWEEP_DISMISS_ROUNDS, poll_interval=1.5,
)
return outcome or "unrecognized_state"
def _open_stage_modal(driver, config, row_y):
# A single click-then-check here was found live to intermittently miss
# (CLAUDE.md already documents this exact failure mode for the
# mailbox/cafe icons: "missed the first click and worked on retry") --
# retry-with-verify like every other click-then-confirm step in this
# module, rather than aborting on the first miss.
for attempt in range(1, STAGE_ENTER_RETRIES + 1):
driver.click(config.EVENT_STAGE_ENTER_X, row_y)
driver.wait(2)
if _is_stage_modal_open(driver, config):
return True
print(f"[event_sweep] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})")
return False
def _click_sweep_start_and_verify(driver, config):
# Same missed-click failure class as _open_stage_modal -- a bare single
# click here was found live to leave neither the AP-usage-confirm nor
# the AP-purchase dialog detected, aborting the run at the very last
# step before it would have actually spent AP. Retry-with-verify like
# every other click in this module.
for attempt in range(1, SWEEP_START_RETRIES + 1):
driver.click(*config.EVENT_SWEEP_START_BUTTON)
driver.wait(1.5)
if _is_sweep_usage_confirm(driver, config) or _is_ap_purchase_prompt(driver, config):
return True
print(f"[event_sweep] sweep confirm/AP-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})")
return False
def _sweep_target(driver, config, stage, count):
print(f"[event_sweep] --- target stage {stage} x {count} ---")
row_y, saw_any_valid_row, confirmed_wrong_page = _find_stage_row(driver, config, stage)
if row_y is None:
if confirmed_wrong_page:
return "wrong_page"
if not saw_any_valid_row:
print("[event_sweep] gave up waiting for stage rows without ever confirming finished-event text -- treating as wrong page anyway (see scratchpad debug screenshot)")
return "wrong_page"
print(f"[event_sweep] stage {stage} not found in the visible stage list")
return "stage_not_found"
if not _open_stage_modal(driver, config, row_y):
print("[event_sweep] stage info panel not detected, aborting")
return "unrecognized_state"
if not _set_sweep_count(driver, config, count):
print("[event_sweep] could not confirm sweep count was raised (stage may not be SSS-cleared/sweepable yet) -- aborting without spending AP")
_close_stage_modal(driver, config)
return "not_sweepable"
if not _click_sweep_start_and_verify(driver, config):
print("[event_sweep] sweep-usage confirmation not detected, aborting without further input")
_close_stage_modal(driver, config)
return "unrecognized_state"
if _is_ap_purchase_prompt(driver, config):
print("[event_sweep] insufficient AP for this sweep -- cancelling without purchasing")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
_close_stage_modal(driver, config)
return "inadequate_ap"
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1.5)
print("[event_sweep] sweep confirmed, waiting for results")
outcome = _watch_sweep_result(driver, config)
print(f"[event_sweep] result: {outcome}")
if outcome == "prompted_to_purchase":
# See _watch_sweep_result's own comment -- cancel this dialog
# explicitly rather than let _close_stage_modal's plain Escape loop
# be the only thing standing between it and a real AP purchase.
print("[event_sweep] AP-purchase prompt detected after the sweep -- cancelling without purchasing")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
if not _close_stage_modal(driver, config):
print("[event_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
return outcome
def _rotation_target(config):
stage_min = getattr(config, "EVENT_SWEEP_ROTATION_STAGE_MIN", None)
if not stage_min:
return None
stage_max = config.EVENT_SWEEP_ROTATION_STAGE_MAX
span = stage_max - stage_min + 1
stage = stage_min + (datetime.date.today().toordinal() % span)
return (stage, config.EVENT_SWEEP_ROTATION_COUNT)
def run(driver, config):
driver.focus_game()
ap = navigation.current_ap(driver)
if ap is not None and ap < config.SWEEP_MIN_AP:
print(f"[event_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do")
return
rotation = _rotation_target(config)
if not rotation:
print("[event_sweep] no rotation target configured (config.EVENT_SWEEP_ROTATION_STAGE_MIN is unset), nothing to do")
return
stage, count = rotation
print(f"[event_sweep] today's rotation target: stage {stage}")
outcome = None
for attempt in range(1, WRONG_PAGE_RETRIES + 1):
# No wait between reaching home and clicking the badge -- see
# _open_event_screen's own docstring for why: the current event is
# the carousel's default item right after landing on home, and it
# rotates away quickly, so any delay here (the original design
# waited WRONG_PAGE_RETRY_WAIT=12s, exactly backwards) just means
# missing the correct item before the click even happens.
navigation.return_to_home(driver)
if not _open_event_screen(driver, config):
print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES})")
continue
outcome = _sweep_target(driver, config, stage, count)
if outcome in ("wrong_page", "stage_not_found"):
# stage_not_found is treated as retryable too, not just
# wrong_page: this event's target range (9-12) is always
# supposed to sit within the last 5 rows of a fully-rendered
# correct page (see _find_stage_row), so failing to find it
# there -- once a stable, settled read confirms it's genuinely
# absent, not just a still-rendering page -- is far more likely
# to mean "this isn't actually the event we think it is" than
# "the right page legitimately lacks this stage." Real user
# report (2026-07-29): a single stray/unstable OCR read used to
# get treated as "confirmed right page, stage missing" and exit
# immediately with no retry at all -- see _find_stage_row's own
# stability fix and this module's docstring for the full story.
print(f"[event_sweep] landed on the wrong event page (attempt {attempt}/{WRONG_PAGE_RETRIES}, outcome={outcome}) -- retrying immediately")
continue
break
else:
print(f"[event_sweep] could not reach the current event's stage list after {WRONG_PAGE_RETRIES} attempts, giving up")
if outcome is not None and outcome not in ("swept", "wrong_page", "stage_not_found"):
print(f"[event_sweep] target stage {stage} ended in '{outcome}'")
if not navigation.return_to_home(driver):
print("[event_sweep] warning: could not confirm return to home screen")
print("[event_sweep] Done.")