ba-auto-daily/ba_auto/tasks/story_sweep_hard.py
Nik Afiq a5d70e5ec6 Refactor story_sweep.py to align with story_sweep_hard.py best practices and add campaign-active guard
- Rewrote story_sweep.py to incorporate fixes from story_sweep_hard.py's Phase 21, addressing latent bugs and improving functionality.
- Introduced a campaign-active guard for Normal mode, ensuring AP is only spent when the reward campaign banner is visible.
- Added explicit Normal-tab navigation and self-healing checks to maintain task screen integrity.
- Updated click functions for MAX and PLUS buttons to remove reliance on visible count increases, addressing false-negative issues.
- Implemented OCR verification for the sweep confirmation dialog to prevent misclicks on stacked buttons.
- Enhanced region reading with retry logic for transient OCR failures.
- Added force parameter to run function, allowing bypass of campaign checks via story_sweep_force command.
- Updated ba_daily.py to include new force commands for both story_sweep and story_sweep_hard.
- Documented changes and live-tested functionality to ensure reliability and correctness.
2026-07-22 23:07:43 +09:00

445 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.
TASK_CAMPAIGN_BADGE_RECT, shared with story_sweep.py's own Normal-mode
campaign check added 2026-07-21 -- pixel-identical rect/color, confirmed
live) 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.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_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.")