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

313 lines
13 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.
"""
import datetime
from ba_auto import detector, navigation
OPEN_RETRIES = 3
POST_SWEEP_DISMISS_ROUNDS = 6
MAX_BUTTON_RETRIES = 3
MODAL_CLOSE_RETRIES = 3
WRONG_PAGE_RETRIES = 3
WRONG_PAGE_RETRY_WAIT = 3
STAGE_ENTER_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.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN)
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):
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 _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 -- a finished/
# stale event's Quest tab shows plain "period ended" text instead of
# stage-row cards, so all 5 reads coming back empty is a strong signal
# we're on the wrong page entirely (see module docstring), distinct from
# "right page, this stage just isn't among the visible rows."
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
saw_any_valid_row = False
for row_y in config.EVENT_STAGE_ROW_Y:
label = detector.read_int(_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 _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):
def click_result_button(d):
pos = _find_result_button(d, config)
if pos:
d.click(*pos)
d.wait(1.5)
ends = {
(lambda d, c: _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 _sweep_target(driver, config, stage, count):
print(f"[event_sweep] --- target stage {stage} x {count} ---")
row_y, saw_any_valid_row = _find_stage_row(driver, config, stage)
if row_y is None:
if not saw_any_valid_row:
print("[event_sweep] no stage-row numbers recognized at all -- likely on the wrong/stale event page")
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"
driver.click(*config.EVENT_SWEEP_START_BUTTON)
driver.wait(1.5)
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"
if not _is_sweep_usage_confirm(driver, config):
print("[event_sweep] sweep-usage confirmation not detected, aborting without further input")
_close_stage_modal(driver, config)
return "unrecognized_state"
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 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()
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):
if not _open_event_screen(driver, config):
print(f"[event_sweep] could not confirm event screen is open (attempt {attempt}/{WRONG_PAGE_RETRIES})")
navigation.return_to_home(driver)
driver.wait(WRONG_PAGE_RETRY_WAIT)
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}) -- returning home to retry")
navigation.return_to_home(driver)
driver.wait(WRONG_PAGE_RETRY_WAIT)
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.")