ba-auto-daily/ba_auto/tasks/event_sweep.py

689 lines
37 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).
"""
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):
saw_any_valid_row = False
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}'")
if label is not None:
saw_any_valid_row = True
if label == stage:
return row_y, saw_any_valid_row
return None, saw_any_valid_row
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.
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
for attempt in range(1, STAGE_ROW_SCAN_ATTEMPTS + 1):
row_y, saw_any_valid_row = _scan_stage_rows_once(driver, config, stage)
if row_y is not None or saw_any_valid_row:
return row_y, saw_any_valid_row, False
if _is_finished_event_page(driver, config):
print("[event_sweep] finished-event page text detected -- confirmed wrong page")
return None, False, True
print(f"[event_sweep] no stage-row numbers recognized on scan {attempt}/{STAGE_ROW_SCAN_ATTEMPTS} -- screen may still be settling")
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 == "wrong_page":
print(f"[event_sweep] landed on the wrong event page (attempt {attempt}/{WRONG_PAGE_RETRIES}) -- 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"):
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.")