- Introduced `story_sweep.py` for Normal/Hard story AP sweeping, allowing users to spend AP on randomly selected stages. - Updated `CLAUDE.md` to clarify the OCR policy, emphasizing the need to port OCR-driven logic from the reference implementation rather than substituting with non-OCR methods. - Modified `README.md` and `plan.md` to reflect the new story sweep feature and its operational details. - Adjusted `setup.sh` to include the new command for running the story sweep. - Enhanced `driver.py` with a new scroll function for better interaction with the game UI. - Updated configuration mappings in `config.py` to support the new story sweep functionality. - Refined existing task modules to ensure consistent state verification and error handling.
158 lines
6.3 KiB
Python
158 lines
6.3 KiB
Python
"""Normal story AP sweep. Reference: baas-reference/module/explore_tasks/sweep_task.py
|
|
and task_utils.py (to_region/to_normal_event + an OCR-driven per-stage claim loop).
|
|
|
|
That reference flow needs OCR (to read the current region number and match
|
|
stage-name text via swipe_search_target_str) and per-locale template assets we
|
|
don't have. This client exposes a much simpler path for the same goal (burn AP
|
|
via already-3-starred stages): each stage's own info panel has a "掃討" (sweep)
|
|
sub-panel with a count stepper (MIN/-/+/MAX) and a start button. So instead of
|
|
porting the OCR-based region/stage lookup, this picks the latest unlocked
|
|
region by spamming the "next region" arrow until it stops advancing (a plain
|
|
state-change check, no OCR), then picks one of its stages essentially at
|
|
random (see _pick_random_stage_row) and sweeps it with the in-game MAX count.
|
|
"""
|
|
import random
|
|
|
|
from ba_auto import navigation
|
|
|
|
OPEN_RETRIES = 3
|
|
POST_SWEEP_DISMISS_ROUNDS = 6
|
|
STAGE_MODAL_DIM_MAX_CHANNEL = 150
|
|
MAX_BUTTON_RETRIES = 3
|
|
MODAL_CLOSE_RETRIES = 3
|
|
|
|
|
|
def _is_stage_modal_open(driver, config):
|
|
r, g, b = driver.color_at(*config.STAGE_MODAL_PROBE)
|
|
return r < STAGE_MODAL_DIM_MAX_CHANNEL and g < STAGE_MODAL_DIM_MAX_CHANNEL and b < STAGE_MODAL_DIM_MAX_CHANNEL
|
|
|
|
|
|
def _count_raised_above_one(driver, config):
|
|
# The "-" stepper button is flat grey while count == 1 (its default,
|
|
# disabled at the minimum) and turns vivid orange once raised -- cheap
|
|
# way to confirm the MAX click actually registered without needing OCR
|
|
# on the count itself.
|
|
r, g, b = driver.color_at(*config.SWEEP_MINUS_BUTTON_PROBE)
|
|
return r > 200 and g < 180 and b < 100
|
|
|
|
|
|
def _open_task_screen(driver, config):
|
|
for attempt in range(1, OPEN_RETRIES + 1):
|
|
driver.click(*config.WORK_ICON)
|
|
driver.wait(2)
|
|
if navigation.is_on_subscreen(driver):
|
|
break
|
|
print(f"[story_sweep] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
|
else:
|
|
return False
|
|
|
|
for attempt in range(1, OPEN_RETRIES + 1):
|
|
driver.click(*config.TASK_CARD)
|
|
driver.wait(2)
|
|
if navigation.is_on_subscreen(driver):
|
|
return True
|
|
print(f"[story_sweep] task screen not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _go_to_latest_region(driver, config):
|
|
print("[story_sweep] advancing to the latest unlocked region")
|
|
x, y = config.REGION_RIGHT_ARROW
|
|
for _ in range(config.REGION_RIGHT_ARROW_MAX_CLICKS):
|
|
driver.click(x, y)
|
|
driver.wait(1)
|
|
|
|
|
|
def _pick_random_stage_row(driver, config):
|
|
# Scrolling this list to either extreme always shows exactly 4 full
|
|
# stage rows, since every region has at least 5 stages -- pick one
|
|
# extreme at random, then a random one of its 4 rows. This isn't
|
|
# perfectly uniform across a region's 5-6 stages (the middle ones are
|
|
# reachable from both extremes and so are somewhat more likely), but it
|
|
# avoids OCR or generic scroll-enumeration entirely.
|
|
x, y = config.STAGE_LIST_SCROLL_POINT
|
|
if random.random() < 0.5:
|
|
driver.scroll(x, y, "up", config.STAGE_LIST_SCROLL_CLICKS)
|
|
row_y = random.choice(config.STAGE_ROWS_AT_TOP_Y)
|
|
else:
|
|
driver.scroll(x, y, "down", config.STAGE_LIST_SCROLL_CLICKS)
|
|
row_y = random.choice(config.STAGE_ROWS_AT_BOTTOM_Y)
|
|
driver.wait(0.5)
|
|
return row_y
|
|
|
|
|
|
def _dismiss_sweep_result(driver, config):
|
|
# Sweep completion shows a reward summary that needs dismissing; the
|
|
# exact number of screens varies with what dropped. Once the underlying
|
|
# 任務情報 modal reappears, its "Enter" hotkey is bound to the live
|
|
# "任務開始" (start manual mission) button, not a no-op -- confirmed live
|
|
# that pressing Enter there would burn AP on a real battle attempt. So
|
|
# check for the modal's return before every press and stop immediately,
|
|
# rather than trusting a fixed round count to line up exactly with the
|
|
# number of reward screens.
|
|
for _ in range(POST_SWEEP_DISMISS_ROUNDS):
|
|
if _is_stage_modal_open(driver, config):
|
|
return
|
|
driver.keypress("Return")
|
|
driver.wait(1.5)
|
|
|
|
|
|
def _close_stage_modal(driver, config):
|
|
# Escape does not close this modal (confirmed live: it stayed open with
|
|
# focus on the live "任務開始" button after two Escape presses) -- the
|
|
# only reliable close path is clicking its own X button, verified.
|
|
for _ in range(MODAL_CLOSE_RETRIES):
|
|
if not _is_stage_modal_open(driver, config):
|
|
return True
|
|
driver.click(*config.STAGE_MODAL_CLOSE_BUTTON)
|
|
driver.wait(1)
|
|
return not _is_stage_modal_open(driver, config)
|
|
|
|
|
|
def run(driver, config):
|
|
driver.focus_game()
|
|
|
|
if not _open_task_screen(driver, config):
|
|
print("[story_sweep] could not confirm task screen is open, aborting without pressing further keys")
|
|
return
|
|
|
|
_go_to_latest_region(driver, config)
|
|
|
|
row_y = _pick_random_stage_row(driver, config)
|
|
driver.click(config.STAGE_ENTER_X, row_y)
|
|
driver.wait(2)
|
|
|
|
if not _is_stage_modal_open(driver, config):
|
|
print("[story_sweep] stage info panel not detected, aborting")
|
|
if navigation.is_on_subscreen(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
return
|
|
|
|
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
|
|
driver.click(*config.SWEEP_MAX_BUTTON)
|
|
driver.wait(0.8)
|
|
if _count_raised_above_one(driver, config):
|
|
break
|
|
print(f"[story_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
|
|
else:
|
|
print("[story_sweep] could not confirm sweep count was raised, aborting without spending AP")
|
|
_close_stage_modal(driver, config)
|
|
if navigation.is_on_subscreen(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
return
|
|
|
|
driver.click(*config.SWEEP_START_BUTTON)
|
|
driver.wait(2)
|
|
print("[story_sweep] sweep started, waiting for results")
|
|
_dismiss_sweep_result(driver, config)
|
|
|
|
if not _close_stage_modal(driver, config):
|
|
print("[story_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
|
|
return
|
|
if navigation.is_on_subscreen(driver):
|
|
driver.keypress("Escape")
|
|
driver.wait(1.5)
|
|
print("[story_sweep] Done.")
|