- 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.
787 lines
42 KiB
Python
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.")
|