ba-auto-daily/ba_auto/tasks/story_sweep.py
Nik Afiq 0707487934 Enhance automation project with story sweep feature and OCR policy updates
- 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.
2026-07-05 20:19:35 +09:00

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.")