514 lines
23 KiB
Python
514 lines
23 KiB
Python
"""Normal story AP sweep. Reference: baas-reference/module/explore_tasks/sweep_task.py
|
||
and task_utils.py.
|
||
|
||
Ported per CLAUDE.md's "OCR policy" and Handoff.md: sweeps a config-driven
|
||
list of exact (region, stage, count) targets (config.STORY_SWEEP_TARGETS),
|
||
navigating to each one deterministically instead of the previous "latest
|
||
unlocked region, then a random stage" heuristic -- see plan.md Phase 9's
|
||
retrospective for why that heuristic was a mistake.
|
||
|
||
`_rotation_target` adds a second, date-derived target on top of that static
|
||
list, per explicit user direction: rather than grinding one fixed stage in
|
||
their current last region every run, it cycles through that region's stages
|
||
one per day (a plain date-ordinal modulo, so the cycle doesn't skip or
|
||
repeat around a year boundary). See config.py's STORY_SWEEP_ROTATION_*
|
||
constants.
|
||
|
||
- `_go_to_region` ports task_utils.py::to_region: OCR the current region
|
||
number, click the exact delta, re-check, bounded loop.
|
||
- `_find_stage_row` is a scoped-down port of core/image.py's
|
||
swipe_search_target_str: OCR each visible stage row's label and match it
|
||
against the configured target, rather than grabbing a random row.
|
||
- `_watch_sweep_result` is built on navigation.wait_for_state, this
|
||
project's scoped port of core/picture.py::co_detect, and returns a named
|
||
outcome ("swept", "inadequate_ap", "unrecognized_state", ...) the way the
|
||
reference's start_sweep does, instead of a single generic "Done".
|
||
|
||
The MAX-button click-then-verify and the modal's own X-button close (both
|
||
calibrated and confirmed live in Phase 9) are reused unchanged -- see
|
||
plan.md Phase 9/10.
|
||
|
||
**2026-07-21 rewrite**: this module predates story_sweep_hard.py's own live
|
||
debugging (plan.md Phase 21), which found several real bugs in patterns this
|
||
module had originally established and story_sweep_hard.py had copied
|
||
verbatim. Per explicit user direction ("match the hard implementation on
|
||
best practices"), the same fixes are applied here too, even though most were
|
||
never actually triggered in production (this module's own
|
||
config.STORY_SWEEP_TARGETS is empty by default, so historically only one
|
||
rotation target ever ran per invocation -- never enough iterations to expose
|
||
a multi-target bug):
|
||
|
||
- An explicit Normal-tab guard (`_open_normal_tab`, config.NORMAL_TAB) and a
|
||
loop-level self-heal (`_ensure_task_screen`), replacing the removed
|
||
per-path "if on subscreen, press Escape" cleanup that used to fire after
|
||
*every* target (success or abort) and would back all the way out of the
|
||
region browser -- confirmed live as a real bug in story_sweep_hard.py,
|
||
same copied code here. The Normal-tab guard is new, not just a bugfix:
|
||
story_sweep_hard.py can now run earlier in the same `q4h` preset sequence
|
||
and leave the Hard tab selected, which this module never had to account
|
||
for before both tasks coexisted.
|
||
- `_click_max`/`_click_plus` no longer require the sweep count to visibly
|
||
rise above 1 as proof a click landed -- low AP can legitimately cap the
|
||
count at its floor, which is visually indistinguishable from "the click
|
||
didn't register." `_click_plus` also now forces a known baseline via
|
||
`_click_min` before applying "+" raises, porting bounty.py's own
|
||
`_click_min_and_verify` fix for a stepper that remembers its last-used
|
||
value across opens (see CLAUDE.md/plan.md Phase 15) -- this module's
|
||
original `_click_plus_and_verify` never did that, a latent version of the
|
||
exact overspend risk bounty.py hit for real.
|
||
- `_confirm_dialog_is_sweep` OCR-verifies the confirm dialog's own text
|
||
before the one irreversible click, closing the same two-stacked-button
|
||
hazard (cyan 掃討開始 directly above a gold 任務開始 real-battle button)
|
||
config.py's own SWEEP_MAX_BUTTON comment already documented for this
|
||
exact tabbed modal layout but this module never actually guarded against.
|
||
- `_watch_sweep_result` gained the same `clicked_any`-gated second `ends`
|
||
condition event_sweep.py/story_sweep_hard.py needed: the post-sweep flow
|
||
can land back on the bare region browser instead of leaving the modal
|
||
open, which the original "swept" condition required.
|
||
- `_read_current_region` retries a None OCR read a couple of times before
|
||
giving up, matching story_sweep_hard.py's own REGION_READ_RETRIES fix for
|
||
a transient render/settle race confirmed live on the same region browser.
|
||
- A campaign-active guard, genuinely new (no reference equivalent -- see
|
||
story_sweep_hard.py's own docstring for why): refuses to spend AP unless
|
||
the pink "キャンペーン中" reward-campaign banner is showing for the Normal
|
||
tab specifically (config.TASK_CAMPAIGN_BADGE_RECT, shared with
|
||
story_sweep_hard.py's own check -- pixel-identical rect/color, confirmed
|
||
live 2026-07-21, only the tab name in the banner text differs). Pass
|
||
force=True (wired to the `story_sweep_force` CLI command) to bypass it.
|
||
"""
|
||
import datetime
|
||
|
||
from ba_auto import detector, navigation
|
||
|
||
OPEN_RETRIES = 3
|
||
NORMAL_TAB_RETRIES = 3
|
||
POST_SWEEP_DISMISS_ROUNDS = 6
|
||
STAGE_MODAL_DIM_MAX_CHANNEL = 150
|
||
MODAL_CLOSE_RETRIES = 3
|
||
REGION_READ_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 _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):
|
||
# 掃討開始 always raises a "use N AP to sweep M times?" confirmation
|
||
# before actually sweeping. Its OK button is this bright cyan.
|
||
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN)
|
||
|
||
|
||
def _is_ap_purchase_prompt(driver, config):
|
||
# If AP is too low for even one sweep, a visually similar dialog appears
|
||
# at the *same* OK-button position but titled "AP購入" (spend real
|
||
# Pyroxene to buy more AP) with a gold/yellow OK instead of cyan --
|
||
# confirmed live by deliberately emptying the sweep count at low AP.
|
||
# Told apart from the safe confirm above by color, not position.
|
||
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD)
|
||
|
||
|
||
def _confirm_dialog_is_sweep(driver, config):
|
||
"""OCR-verify the confirm dialog reached after clicking 掃討開始 is
|
||
genuinely the sweep-usage confirm ("APをN使用して、掃討をN回行いますか?"),
|
||
not some other cyan-styled confirmation that happens to satisfy
|
||
_is_sweep_usage_confirm's color-only check -- ports bounty.py's own
|
||
_confirm_dialog_is_sweep fix (see that module and story_sweep_hard.py for
|
||
the hazard class this guards against: this exact tabbed modal has a gold
|
||
任務開始 "start mission" button directly beneath the cyan 掃討開始 one,
|
||
both showing an identical AP-cost preview -- see config.py's
|
||
SWEEP_MAX_BUTTON comment). Reuses config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT
|
||
directly, confirmed live to crop this modal's confirm text correctly too
|
||
since it's the same shared "通知" dialog component. Substring match on
|
||
"掃討" rather than an exact match, matching this project's own
|
||
established dialog-classification convention.
|
||
"""
|
||
text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn")
|
||
return "掃討" in text
|
||
|
||
|
||
def _find_result_button(driver, config):
|
||
# The "掃討完了" (sweep complete) results screen shows a SKIP button
|
||
# (skips the reward-reveal animation) and then, once settled, a final
|
||
# OK button -- both the same cyan as the usage-confirm OK, but ~120px
|
||
# apart vertically. Finding whichever is showing by color avoids
|
||
# hardcoding both positions.
|
||
return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN)
|
||
|
||
|
||
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 _open_normal_tab(driver, config):
|
||
for attempt in range(1, NORMAL_TAB_RETRIES + 1):
|
||
driver.click(*config.NORMAL_TAB)
|
||
driver.wait(1)
|
||
if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB):
|
||
return True
|
||
print(f"[story_sweep] Normal tab not confirmed active (attempt {attempt}/{NORMAL_TAB_RETRIES})")
|
||
return False
|
||
|
||
|
||
def _campaign_active(driver, config):
|
||
return detector.region_contains_color(config.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_CAMPAIGN_BADGE_RGB)
|
||
|
||
|
||
def _ensure_task_screen(driver, config):
|
||
"""Re-verify we're still on the task screen's Normal tab before each
|
||
target, self-healing from any navigation drift between targets the same
|
||
way ba_daily.py's own _ensure_home does before each task, and the same
|
||
way story_sweep_hard.py's own _ensure_hard_screen does between its
|
||
targets -- see that function's docstring for the real incident this
|
||
pattern was built to fix (a leftover per-path Escape press that backed
|
||
out of the region browser after every single target).
|
||
"""
|
||
if _color_in_range(driver.color_at(*config.NORMAL_TAB_ACTIVE_PROBE), config.NORMAL_TAB_ACTIVE_RGB):
|
||
return True
|
||
print("[story_sweep] task screen (Normal tab) not confirmed before target -- re-opening")
|
||
return _open_task_screen(driver, config) and _open_normal_tab(driver, config)
|
||
|
||
|
||
def _read_current_region(driver, config):
|
||
"""OCR the current region number, retrying a None read a couple of times
|
||
before giving up -- matching story_sweep_hard.py's own fix for a
|
||
transient render/settle race confirmed live on this same region browser
|
||
(a screenshot that read as None was, moments later, confirmed by eye to
|
||
be a perfectly clean and legible digit).
|
||
"""
|
||
for attempt in range(1, REGION_READ_RETRIES + 1):
|
||
cur = detector.read_int(config.REGION_NUMBER_OCR_RECT)
|
||
if cur is not None:
|
||
return cur
|
||
if attempt < REGION_READ_RETRIES:
|
||
driver.wait(1)
|
||
return None
|
||
|
||
|
||
def _region_arrow_visible(driver, config, center):
|
||
# Both arrows render as a solid navy-blue "<"/">" chevron on a
|
||
# light-blue backdrop when present. The last region in a given direction
|
||
# (or a locked one, per the reference's own "region-unavailable"
|
||
# template check) simply omits the arrow rather than greying it out --
|
||
# confirmed live: at region 30 (this account's current last region), the
|
||
# spot where the right arrow would be was plain background.
|
||
#
|
||
# Scans a small box around `center` rather than probing a single fixed
|
||
# point: a chevron is concave, and a centroid-derived single point
|
||
# landed in the notch between its two strokes -- reading as "absent"
|
||
# even while the glyph was clearly rendered a few pixels away. See
|
||
# plan.md Phase 10.
|
||
cx, cy = center
|
||
rect = (cx - 40, cy - 35, cx + 40, cy + 35)
|
||
return detector.region_contains_color(rect, (40, 70, 120), (100, 130, 190))
|
||
|
||
|
||
def _go_to_region(driver, config, target_region):
|
||
cur = _read_current_region(driver, config)
|
||
if cur is None:
|
||
print("[story_sweep] could not OCR the current region number")
|
||
return False
|
||
print(f"[story_sweep] current region {cur}, target region {target_region}")
|
||
|
||
for attempt in range(1, config.REGION_NAV_MAX_ATTEMPTS + 1):
|
||
if cur == target_region:
|
||
return True
|
||
going_left = cur > target_region
|
||
arrow_pos = config.REGION_LEFT_ARROW if going_left else config.REGION_RIGHT_ARROW
|
||
if not _region_arrow_visible(driver, config, arrow_pos):
|
||
direction = "left" if going_left else "right"
|
||
print(f"[story_sweep] region {target_region} unreachable -- no {direction} arrow at region {cur}")
|
||
return False
|
||
|
||
clicks = abs(cur - target_region)
|
||
for _ in range(clicks):
|
||
driver.click(*arrow_pos)
|
||
driver.wait(1)
|
||
|
||
new_cur = _read_current_region(driver, config)
|
||
if new_cur is None or new_cur == cur:
|
||
print(f"[story_sweep] region number unchanged after {clicks} click(s) (attempt {attempt}/{config.REGION_NAV_MAX_ATTEMPTS})")
|
||
return False
|
||
cur = new_cur
|
||
|
||
print(f"[story_sweep] gave up navigating to region {target_region} after {config.REGION_NAV_MAX_ATTEMPTS} attempts")
|
||
return False
|
||
|
||
|
||
def _normalize_label(text):
|
||
# OCR sometimes reads the row's dash as a different dash-like glyph, or
|
||
# picks up stray whitespace -- normalize before comparing.
|
||
return text.strip().upper().replace("—", "-").replace("–", "-").replace(" ", "")
|
||
|
||
|
||
def _label_suffix(label):
|
||
# Compare only the part after the dash (e.g. "2" in "30-2", "A" in
|
||
# "30-A"), not the full "{region}-{stage}" string. Confirmed live: this
|
||
# font's leading region-number digit is read unreliably by OCR (e.g. "3"
|
||
# misread as "2") even after threshold preprocessing, while the stage
|
||
# suffix after the dash reads correctly across every row tested -- and we
|
||
# don't need the region digit anyway, since _go_to_region has already
|
||
# independently confirmed we're in the right region. See plan.md Phase 10.
|
||
parts = [p for p in _normalize_label(label).split("-") if p]
|
||
return parts[-1] if parts else ""
|
||
|
||
|
||
def _row_label_rect(config, row_y):
|
||
x1, x2 = config.STAGE_LABEL_OCR_X
|
||
top_pad, bottom_pad = config.STAGE_LABEL_OCR_Y_PAD
|
||
return (x1, row_y - top_pad, x2, row_y - bottom_pad)
|
||
|
||
|
||
def _find_stage_row(driver, config, region, stage):
|
||
# Scoped-down swipe_search_target_str (see module docstring): this
|
||
# client's stage list only ever needs the two already-calibrated scroll
|
||
# extremes checked, not arbitrary swipe-and-retry.
|
||
target_suffix = str(stage)
|
||
x, y = config.STAGE_LIST_SCROLL_POINT
|
||
|
||
driver.scroll(x, y, "up", config.STAGE_LIST_SCROLL_CLICKS)
|
||
driver.wait(0.5)
|
||
for row_y in config.STAGE_ROWS_AT_TOP_Y:
|
||
label = detector.read_text(_row_label_rect(config, row_y), whitelist="0123456789-A")
|
||
print(f"[story_sweep] row @ {row_y} (scrolled up): read '{label}'")
|
||
if _label_suffix(label) == target_suffix:
|
||
return row_y
|
||
|
||
driver.scroll(x, y, "down", config.STAGE_LIST_SCROLL_CLICKS)
|
||
driver.wait(0.5)
|
||
for row_y in config.STAGE_ROWS_AT_BOTTOM_Y:
|
||
label = detector.read_text(_row_label_rect(config, row_y), whitelist="0123456789-A")
|
||
print(f"[story_sweep] row @ {row_y} (scrolled down): read '{label}'")
|
||
if _label_suffix(label) == target_suffix:
|
||
return row_y
|
||
|
||
return None
|
||
|
||
|
||
def _click_min(driver, config):
|
||
driver.click(*config.SWEEP_MIN_BUTTON)
|
||
driver.wait(0.8)
|
||
|
||
|
||
def _click_max(driver, config):
|
||
"""Click the MAX button once. Deliberately does NOT require the sweep
|
||
count to visibly rise above 1 as proof the click landed -- see
|
||
story_sweep_hard.py's own _click_max for the real incident this fixes
|
||
(low AP can legitimately cap the count at its floor, indistinguishable
|
||
from "the click didn't register"; 11 real, affordable targets were
|
||
wrongly skipped by the old verify-and-abort design in that module before
|
||
this fix). The downstream confirm-dialog checks (_is_ap_purchase_prompt/
|
||
_is_sweep_usage_confirm/_confirm_dialog_is_sweep) are the real safety net
|
||
regardless of what the stepper visually showed here.
|
||
"""
|
||
driver.click(*config.SWEEP_MAX_BUTTON)
|
||
driver.wait(0.8)
|
||
|
||
|
||
def _click_plus(driver, config, count):
|
||
"""Raise the sweep count to an exact configured value via "+".
|
||
|
||
Forces a known baseline of 1 via _click_min first -- the stepper
|
||
remembers its last-used value across opens (confirmed live for real via
|
||
bounty.py's own overspend incident, plan.md Phase 15/CLAUDE.md), so
|
||
clicking "+" count-1 times on top of an unknown starting value could
|
||
under- or over-shoot the intended count. This module's original
|
||
_click_plus_and_verify never reset to a baseline first -- a latent
|
||
version of the exact bug bounty.py hit for real, never triggered here
|
||
only because config.STORY_SWEEP_TARGETS has always been empty in
|
||
practice. Same as _click_max, does not require a visible post-click
|
||
raise as proof of success.
|
||
"""
|
||
_click_min(driver, config)
|
||
for _ in range(count - 1):
|
||
driver.click(*config.SWEEP_PLUS_BUTTON)
|
||
driver.wait(0.8)
|
||
|
||
|
||
def _set_sweep_count(driver, config, count):
|
||
if count == "max":
|
||
_click_max(driver, config)
|
||
else:
|
||
_click_plus(driver, config, count)
|
||
|
||
|
||
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 _watch_sweep_result(driver, config):
|
||
"""Confirmed live 2026-07-20/21 (story_sweep_hard.py, plan.md Phase 21)
|
||
that this shared modal's post-sweep flow can land all the way back on
|
||
the bare region browser instead of leaving the 任務情報 modal open --
|
||
the same terminal-state gap event_sweep.py hit historically. Ports
|
||
event_sweep.py's/story_sweep_hard.py's own `clicked_any`-gated second
|
||
`ends` condition: only treat "no modal, no result button" as "swept"
|
||
once we've actually clicked through at least one result-screen button,
|
||
so an immediate read on the very first check (before any SKIP/OK
|
||
sequence has started) still can't be mistaken for a genuine completion.
|
||
"""
|
||
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_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 _sweep_target(driver, config, region, stage, count):
|
||
print(f"[story_sweep] --- target {region}-{stage} x {count} ---")
|
||
|
||
if not _go_to_region(driver, config, region):
|
||
return "region_unavailable"
|
||
|
||
row_y = _find_stage_row(driver, config, region, stage)
|
||
if row_y is None:
|
||
print(f"[story_sweep] stage {region}-{stage} not found in the visible stage list")
|
||
return "stage_not_found"
|
||
|
||
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")
|
||
return "unrecognized_state"
|
||
|
||
_set_sweep_count(driver, config, count)
|
||
|
||
driver.click(*config.SWEEP_START_BUTTON)
|
||
driver.wait(1.5)
|
||
|
||
if _is_ap_purchase_prompt(driver, config):
|
||
print("[story_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("[story_sweep] sweep-usage confirmation not detected, aborting without further input")
|
||
_close_stage_modal(driver, config)
|
||
return "unrecognized_state"
|
||
|
||
# Hard safety gate before the one irreversible click in this whole flow
|
||
# -- see module docstring / bounty.py's own real incident. Color already
|
||
# matched above (_is_sweep_usage_confirm); verify the actual dialog text
|
||
# too before committing rather than trusting color alone.
|
||
if not _confirm_dialog_is_sweep(driver, config):
|
||
print("[story_sweep] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming")
|
||
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
|
||
driver.wait(1)
|
||
_close_stage_modal(driver, config)
|
||
return "unrecognized_state"
|
||
|
||
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
||
driver.wait(1.5)
|
||
print("[story_sweep] sweep confirmed, waiting for results")
|
||
outcome = _watch_sweep_result(driver, config)
|
||
print(f"[story_sweep] result: {outcome}")
|
||
|
||
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 outcome
|
||
|
||
|
||
def _rotation_target(config):
|
||
region = getattr(config, "STORY_SWEEP_ROTATION_REGION", None)
|
||
if not region:
|
||
return None
|
||
stage_count = config.STORY_SWEEP_ROTATION_STAGE_COUNT
|
||
stage = (datetime.date.today().toordinal() % stage_count) + 1
|
||
return (region, stage, config.STORY_SWEEP_ROTATION_COUNT)
|
||
|
||
|
||
def run(driver, config, force=False):
|
||
driver.focus_game()
|
||
|
||
ap = navigation.current_ap(driver)
|
||
if ap is not None and ap < config.SWEEP_MIN_AP:
|
||
print(f"[story_sweep] current AP ({ap}) is below the minimum ({config.SWEEP_MIN_AP}) -- skipping, nothing to do")
|
||
return
|
||
|
||
targets = list(config.STORY_SWEEP_TARGETS)
|
||
rotation = _rotation_target(config)
|
||
if rotation:
|
||
print(f"[story_sweep] today's rotation target: region {rotation[0]} stage {rotation[1]}")
|
||
targets.append(rotation)
|
||
|
||
if not targets:
|
||
print("[story_sweep] no targets configured (config.STORY_SWEEP_TARGETS is empty and rotation is disabled), nothing to do")
|
||
return
|
||
|
||
if not _open_task_screen(driver, config):
|
||
print("[story_sweep] could not confirm task screen is open, aborting without pressing further keys")
|
||
return
|
||
|
||
if not _open_normal_tab(driver, config):
|
||
print("[story_sweep] could not confirm Normal tab is selected, aborting without pressing further keys")
|
||
return
|
||
|
||
if force:
|
||
print("[story_sweep] force=True -- skipping campaign check")
|
||
else:
|
||
if not _campaign_active(driver, config):
|
||
print("[story_sweep] no active Normal-task reward campaign detected -- skipping sweep (run story_sweep_force to override)")
|
||
return
|
||
print("[story_sweep] campaign confirmed active, proceeding")
|
||
|
||
for region, stage, count in targets:
|
||
if not _ensure_task_screen(driver, config):
|
||
print(f"[story_sweep] could not confirm/recover the task screen before target {region}-{stage} -- stopping")
|
||
break
|
||
outcome = _sweep_target(driver, config, region, stage, count)
|
||
if outcome == "inadequate_ap":
|
||
print("[story_sweep] insufficient AP -- stopping, not attempting remaining targets")
|
||
break
|
||
if outcome != "swept":
|
||
print(f"[story_sweep] target {region}-{stage} ended in '{outcome}' -- skipping to next target")
|
||
|
||
print("[story_sweep] Done.")
|