- Added `story_sweep_hard.py` to handle a fixed, priority-ordered list of Hard-mode story stages, gated by an active drop-rate/reward campaign. - Introduced `HARD_STORY_SWEEP_TARGETS`, `HARD_TAB`, `HARD_STAGE_ROWS_Y`, and `HARD_CAMPAIGN_BADGE_RECT` in the config for managing Hard sweep settings. - Integrated `story_sweep_hard` and `story_sweep_hard_force` commands into the daily task flow, allowing for optional bypass of the campaign check. - Enhanced navigation and error handling in the sweep process, addressing multiple bugs found during live testing, including navigation-cascade issues and real-money hazards. - Documented the implementation and testing results in `plan.md`, confirming successful live sweeps and robust error handling.
443 lines
21 KiB
Python
443 lines
21 KiB
Python
"""Hard story AP sweep. Reference: baas-reference/module/explore_tasks/sweep_task.py's
|
||
sweep_hard_task, task_utils.py's to_hard_event/to_region.
|
||
|
||
Ports a fixed, user-supplied priority-ordered list of Hard-mode (region,
|
||
stage) targets (config.HARD_STORY_SWEEP_TARGETS), each swept via MAX
|
||
(capped at 3x by the game itself), stopping once AP runs out -- mirroring
|
||
story_sweep.py's own MAX-per-target convention for config.STORY_SWEEP_TARGETS.
|
||
|
||
Reuses story_sweep.py's WORK_ICON/TASK_CARD entry point, region-nav
|
||
(REGION_LEFT_ARROW/RIGHT_ARROW, REGION_NUMBER_OCR_RECT), and stage-info
|
||
modal constants (SWEEP_MAX_BUTTON, SWEEP_START_BUTTON, STAGE_MODAL_CLOSE_BUTTON,
|
||
SWEEP_CONFIRM_*, SWEEP_RESULT_BUTTON_REGION) directly -- confirmed live
|
||
2026-07-20 to be the exact same underlying UI component as Normal mode's.
|
||
Genuinely different about Hard: a Normal/Hard toggle tab (config.HARD_TAB),
|
||
always-exactly-3 fixed stage rows with no scrolling/OCR-label search needed
|
||
(config.HARD_STAGE_ROWS_Y, vs story_sweep's scroll+OCR row search), and a
|
||
campaign-active guard with no reference equivalent at all (baas-reference has
|
||
no concept of a drop-rate campaign) -- per explicit user direction, this
|
||
checks for the pink "キャンペーン中" reward-campaign banner (config.
|
||
HARD_CAMPAIGN_BADGE_RECT) before spending any AP, since Hard sweep is only
|
||
worth running while Hard-task rewards are boosted. Pass force=True (wired to
|
||
the `story_sweep_hard_force` CLI command) to bypass that guard.
|
||
|
||
Also carries forward a hard lesson from bounty.py's own real incident (see
|
||
CLAUDE.md/plan.md): the stage-info modal has TWO stacked action buttons --
|
||
the intended cyan 掃討開始 (start sweep, instant) directly above a gold
|
||
任務開始 (start mission, a REAL manual battle), both showing an identical
|
||
AP-cost preview. _confirm_dialog_is_sweep OCR-verifies the confirm dialog's
|
||
own text before the one irreversible click in this flow, exactly like
|
||
bounty.py's fix, applied here proactively instead of waiting for a live
|
||
near-miss to prove it's needed.
|
||
|
||
A second, genuinely new real-money hazard was found live 2026-07-20 (not
|
||
something bounty.py had to deal with): a Hard stage that already used all 3
|
||
of today's auto-sweep clears still shows a clickable 入場 button on the
|
||
region-browser row, and clicking through to 掃討開始 there raises a
|
||
completely different gold-button "アラート" dialog offering to refill
|
||
today's clear count for 40 real Pyroxene/blue gems. See config.
|
||
HARD_COUNT_FIELD_RECT for the full incident and the two-layer guard
|
||
(_remaining_sweeps_today's pre-check, _is_challenge_count_alert's OCR
|
||
fallback) that closes it.
|
||
"""
|
||
|
||
from ba_auto import detector, navigation
|
||
|
||
OPEN_RETRIES = 3
|
||
HARD_TAB_RETRIES = 3
|
||
# 6 (story_sweep.py's own value) proved too tight live 2026-07-20: a real 3x
|
||
# MAX Hard sweep (up to 3x the reward items of a 1x) hit this budget without
|
||
# ever finding the final result button, ending in "unrecognized_state"
|
||
# instead of "swept" -- the sweep itself still happened for real (AP/rewards
|
||
# already committed at the SWEEP_CONFIRM_BUTTON click, before this ever
|
||
# runs), just misreported. Widened rather than left at parity with
|
||
# story_sweep.py, whose own STORY_SWEEP_TARGETS default is empty (only ever
|
||
# sweeps 1x per run in practice), so it never exercised a comparably large
|
||
# reward-reveal sequence.
|
||
POST_SWEEP_DISMISS_ROUNDS = 10
|
||
STAGE_MODAL_DIM_MAX_CHANNEL = 150
|
||
MODAL_CLOSE_RETRIES = 3
|
||
|
||
|
||
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_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 _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 _remaining_sweeps_today(driver, config):
|
||
"""OCR the stage-info modal's own sweep-count field (white digit on a
|
||
dark navy background, same polarity as arena's Lv. labels -- see
|
||
detector.read_int_white_on_dark) before ever clicking MAX/掃討開始.
|
||
|
||
A stage already at 0 remaining today still shows a clickable 入場 button
|
||
on the region-browser row (HARD_STAGE_ENTER_PROBE_HALF_SIZE's unlocked
|
||
check does NOT catch this) but its modal opens with this count field
|
||
already defaulted to "0" instead of the healthy default of "1" -- see
|
||
config.HARD_COUNT_FIELD_RECT for the real incident this guards against
|
||
(a real gem-cost "refill challenge count?" dialog reached live).
|
||
"""
|
||
return detector.read_int_white_on_dark(config.HARD_COUNT_FIELD_RECT)
|
||
|
||
|
||
def _is_challenge_count_alert(driver, config):
|
||
"""OCR-detect the real-money "挑戦回数が不足しています...回復させますか?"
|
||
alert (see config.HARD_COUNT_FIELD_RECT's comment for the full incident)
|
||
as a fallback safety net in case _remaining_sweeps_today's pre-check
|
||
somehow missed it (e.g. the count changed between the check and the
|
||
click). Substring match on "回数" rather than an exact phrase, matching
|
||
this project's established dialog-classification convention."""
|
||
text = detector.read_text(config.HARD_CHALLENGE_ALERT_TEXT_RECT, psm=6, lang="jpn")
|
||
return "回数" in text
|
||
|
||
|
||
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 CLAUDE.md for the real
|
||
incident it guards against: a real battle got triggered instead of a
|
||
sweep, with every check along that path a generic color/position probe
|
||
and no check on what was actually showing). Reuses
|
||
config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT directly -- confirmed live
|
||
2026-07-20 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 (cafe.py's 衣装/隣 checks, event_sweep.py's
|
||
"終了" check).
|
||
"""
|
||
text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn")
|
||
return "掃討" in text
|
||
|
||
|
||
def _find_result_button(driver, config):
|
||
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_hard] 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_hard] task screen not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
||
return False
|
||
|
||
|
||
def _open_hard_tab(driver, config):
|
||
for attempt in range(1, HARD_TAB_RETRIES + 1):
|
||
driver.click(*config.HARD_TAB)
|
||
driver.wait(1)
|
||
if _color_in_range(driver.color_at(*config.HARD_TAB_ACTIVE_PROBE), config.HARD_TAB_ACTIVE_RGB):
|
||
return True
|
||
print(f"[story_sweep_hard] Hard tab not confirmed active (attempt {attempt}/{HARD_TAB_RETRIES})")
|
||
return False
|
||
|
||
|
||
def _campaign_active(driver, config):
|
||
return detector.region_contains_color(config.HARD_CAMPAIGN_BADGE_RECT, *config.HARD_CAMPAIGN_BADGE_RGB)
|
||
|
||
|
||
def _ensure_hard_screen(driver, config):
|
||
"""Re-verify we're still on the Hard region browser 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.
|
||
|
||
Confirmed live 2026-07-20 this drift is real, not hypothetical: every
|
||
_sweep_target exit path used to end with "if is_on_subscreen: press
|
||
Escape" as a leftover-dialog safety net, copied from story_sweep.py --
|
||
but the region browser itself already satisfies is_on_subscreen, so that
|
||
Escape fired after literally every target (success or abort) and backed
|
||
all the way out to the Work hub. story_sweep.py has the same latent bug
|
||
but never hit it in practice: its own config.STORY_SWEEP_TARGETS is
|
||
empty by default (only the single daily rotation target runs), so its
|
||
loop never reaches a second iteration where the drift would surface.
|
||
Fixed here by dropping those per-path Escape presses entirely (
|
||
_close_stage_modal already leaves us correctly on the region browser)
|
||
and centralizing recovery in this one loop-level check instead.
|
||
|
||
The color check itself is a cheap single screenshot when nothing has
|
||
drifted (the common case); a false positive from some other screen
|
||
coincidentally matching HARD_TAB_ACTIVE_RGB just costs one skipped
|
||
target via _go_to_region's own safe "could not OCR" failure, not a
|
||
misclick, so this doesn't need to be perfectly precise.
|
||
"""
|
||
if _color_in_range(driver.color_at(*config.HARD_TAB_ACTIVE_PROBE), config.HARD_TAB_ACTIVE_RGB):
|
||
return True
|
||
print("[story_sweep_hard] Hard region browser not confirmed before target -- re-opening")
|
||
return _open_task_screen(driver, config) and _open_hard_tab(driver, config)
|
||
|
||
|
||
REGION_READ_RETRIES = 3
|
||
|
||
|
||
def _read_current_region(driver, config):
|
||
"""OCR the current region number, retrying a None read a couple of times
|
||
before giving up. Confirmed live 2026-07-20: a single failed read here
|
||
(against a rect/crop later confirmed visually clean and perfectly
|
||
legible from the same live session) aborted _go_to_region outright,
|
||
and this same region transition (30 -> 27) failed identically across
|
||
two separate real runs -- pointing at a transient render/settle race
|
||
rather than a fundamentally broken crop. A short retry is cheap insurance
|
||
against that race; it does not paper over a genuinely broken crop, since
|
||
a real miscalibration would keep failing across all retries too.
|
||
"""
|
||
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):
|
||
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_hard] could not OCR the current region number")
|
||
return False
|
||
print(f"[story_sweep_hard] 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_hard] 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_hard] region number unchanged after {clicks} click(s) (attempt {attempt}/{config.REGION_NAV_MAX_ATTEMPTS})")
|
||
return False
|
||
cur = new_cur
|
||
|
||
print(f"[story_sweep_hard] gave up navigating to region {target_region} after {config.REGION_NAV_MAX_ATTEMPTS} attempts")
|
||
return False
|
||
|
||
|
||
def _stage_row_unlocked(driver, config, row_y):
|
||
hx, hy = config.HARD_STAGE_ENTER_PROBE_HALF_SIZE
|
||
rect = (config.STAGE_ENTER_X - hx, row_y - hy, config.STAGE_ENTER_X + hx, row_y + hy)
|
||
return detector.region_contains_color(rect, *config.SWEEP_CONFIRM_CYAN)
|
||
|
||
|
||
def _click_max(driver, config):
|
||
"""Click the MAX button once. Deliberately does NOT require the sweep
|
||
count to visibly raise above 1 as proof the click landed (the original
|
||
design, mirroring story_sweep.py's own _click_max_and_verify) --
|
||
confirmed live 2026-07-20 that a low-AP run correctly leaves the count
|
||
at its default of 1 when MAX can only afford exactly 1x (Hard costs 20
|
||
AP/hit, so any remaining balance under 40 caps MAX at 1), which is
|
||
visually indistinguishable from the stepper's own "click didn't land"
|
||
signal (SWEEP_MINUS_BUTTON_PROBE staying grey). Requiring a visible
|
||
raise here caused 11 consecutive real targets to be skipped without even
|
||
being attempted during a real run, despite AP being sufficient for a
|
||
valid 1x sweep on every one of them.
|
||
|
||
The downstream confirm-dialog checks (_is_ap_purchase_prompt/
|
||
_is_sweep_usage_confirm/_confirm_dialog_is_sweep) remain the real safety
|
||
net: they correctly catch both "not enough AP for even 1x" (stops the
|
||
whole run) and "this isn't really a sweep confirmation" (the bounty.py
|
||
hazard class) regardless of what the stepper visually showed here.
|
||
"""
|
||
driver.click(*config.SWEEP_MAX_BUTTON)
|
||
driver.wait(0.8)
|
||
|
||
|
||
def _close_stage_modal(driver, config):
|
||
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 (six real MAX sweeps in one run, all
|
||
genuinely successful -- AP spent, subsequent targets proceeded normally)
|
||
that Hard'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 event_sweep.py's own _watch_sweep_result had to handle (see that
|
||
module's comment for the original incident). Widening
|
||
POST_SWEEP_DISMISS_ROUNDS alone did NOT fix this -- every real sweep that
|
||
run still ended in "unrecognized_state" even with the larger budget,
|
||
proving this was never a timing issue. Ports event_sweep.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):
|
||
print(f"[story_sweep_hard] --- target H{region}-{stage} x MAX ---")
|
||
|
||
if not _go_to_region(driver, config, region):
|
||
return "region_unavailable"
|
||
|
||
row_y = config.HARD_STAGE_ROWS_Y[stage - 1]
|
||
if not _stage_row_unlocked(driver, config, row_y):
|
||
print(f"[story_sweep_hard] stage H{region}-{stage} appears locked/unavailable -- skipping")
|
||
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_hard] stage info panel not detected, aborting")
|
||
return "unrecognized_state"
|
||
|
||
remaining = _remaining_sweeps_today(driver, config)
|
||
if remaining == 0:
|
||
print(f"[story_sweep_hard] H{region}-{stage} already at 0 remaining sweeps today -- skipping without spending AP or reaching the gem-refill prompt")
|
||
_close_stage_modal(driver, config)
|
||
return "daily_limit_reached"
|
||
|
||
_click_max(driver, config)
|
||
|
||
driver.click(*config.SWEEP_START_BUTTON)
|
||
driver.wait(1.5)
|
||
|
||
# Real-money hazard gate, checked before the AP-purchase/sweep-usage
|
||
# checks below -- see config.HARD_COUNT_FIELD_RECT's comment for the
|
||
# full incident (a real "spend 40 gems to refill today's clears?"
|
||
# dialog). The pre-check above should already prevent reaching this, but
|
||
# this is the fallback in case the count changed in between.
|
||
if _is_challenge_count_alert(driver, config):
|
||
print("[story_sweep_hard] challenge-count gem-refill alert detected -- declining via Escape, never confirming a real gem spend")
|
||
driver.keypress("Escape")
|
||
driver.wait(1)
|
||
_close_stage_modal(driver, config)
|
||
return "daily_limit_reached"
|
||
|
||
if _is_ap_purchase_prompt(driver, config):
|
||
print("[story_sweep_hard] 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_hard] 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_hard] 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_hard] sweep confirmed, waiting for results")
|
||
outcome = _watch_sweep_result(driver, config)
|
||
print(f"[story_sweep_hard] result: {outcome}")
|
||
|
||
if not _close_stage_modal(driver, config):
|
||
print("[story_sweep_hard] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
|
||
return outcome
|
||
|
||
|
||
def run(driver, config, force=False):
|
||
driver.focus_game()
|
||
|
||
targets = list(config.HARD_STORY_SWEEP_TARGETS)
|
||
if not targets:
|
||
print("[story_sweep_hard] no targets configured (config.HARD_STORY_SWEEP_TARGETS is empty), nothing to do")
|
||
return
|
||
|
||
if not _open_task_screen(driver, config):
|
||
print("[story_sweep_hard] could not confirm task screen is open, aborting without pressing further keys")
|
||
return
|
||
|
||
if not _open_hard_tab(driver, config):
|
||
print("[story_sweep_hard] could not confirm Hard tab is selected, aborting without pressing further keys")
|
||
return
|
||
|
||
if force:
|
||
print("[story_sweep_hard] force=True -- skipping campaign check")
|
||
else:
|
||
if not _campaign_active(driver, config):
|
||
print("[story_sweep_hard] no active Hard-task reward campaign detected -- skipping sweep (run story_sweep_hard_force to override)")
|
||
return
|
||
print("[story_sweep_hard] campaign confirmed active, proceeding")
|
||
|
||
for region, stage in targets:
|
||
if not _ensure_hard_screen(driver, config):
|
||
print(f"[story_sweep_hard] could not confirm/recover the Hard region browser before target H{region}-{stage} -- stopping")
|
||
break
|
||
outcome = _sweep_target(driver, config, region, stage)
|
||
if outcome == "inadequate_ap":
|
||
print("[story_sweep_hard] insufficient AP -- stopping, not attempting remaining targets")
|
||
break
|
||
if outcome != "swept":
|
||
print(f"[story_sweep_hard] target H{region}-{stage} ended in '{outcome}' -- skipping to next target")
|
||
|
||
print("[story_sweep_hard] Done.")
|