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.
This commit is contained in:
Nik Afiq 2026-07-22 23:07:43 +09:00
parent 7f752d5422
commit a5d70e5ec6
7 changed files with 290 additions and 96 deletions

View File

@ -668,9 +668,9 @@ When debugging on `nik-gpu`, copy relevant screenshots or debug images back into
Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, event_sweep, shop_common, shop_tactical, lesson, arena, and bounty are all migrated to real Python. No Bash feature logic remains. (This list has drifted behind a few other migrated tasks -- login, gem_shop, circle, exit_game -- not tracked here yet; see `ba_daily.py`'s own `TASKS` dict for the true current set.)
- `ba_dailies.sh` is a thin launcher that execs `ba_daily.py`
- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `story_sweep_hard`/`story_sweep_hard_force`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, `bounty`, and default flow to `ba_auto/tasks/`
- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`/`story_sweep_force`, `story_sweep_hard`/`story_sweep_hard_force`, `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, `bounty`, and default flow to `ba_auto/tasks/`
- default flow is `mailbox`, `cafe`, `stamina`
- `story_sweep`, `story_sweep_hard` (and its `story_sweep_hard_force` override), `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and `bounty` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket rather than reclaiming something free. `story_sweep_hard` additionally refuses to run at all unless a Hard-task reward campaign is currently active, unless overridden via `story_sweep_hard_force` -- see `ba_auto/tasks/story_sweep_hard.py` and `plan.md` Phase 21
- `story_sweep` (and its `story_sweep_force` override), `story_sweep_hard` (and its `story_sweep_hard_force` override), `event_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and `bounty` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket rather than reclaiming something free. Both `story_sweep` and `story_sweep_hard` additionally refuse to run at all unless their tab's reward campaign is currently active, unless overridden via `story_sweep_force`/`story_sweep_hard_force` -- see `ba_auto/tasks/story_sweep.py`/`story_sweep_hard.py` and `plan.md` Phases 21-22
- every task, whether run individually or as part of the default flow, self-heals back to the home screen both before it starts and after it ends — `ba_daily.py`'s `_run_task()` calls a retrying `_ensure_home()` before dispatch and wraps the dispatch itself in a `try/finally` calling `navigation.return_to_home()`, so cleanup runs regardless of success, an early-return failure, or an uncaught exception. The pre-task check is self-healing, not a hard gate, per explicit user direction: if `_ensure_home()` still can't confirm home after its own bounded retries, the task is attempted anyway rather than aborted, trusting each task's own click-then-verify steps to fail safely if the starting state really was bad. This is centralized rather than duplicated per-task; see plan.md's "Return-to-home audit" (and its self-heal-not-abort follow-up) for why (a real audit found most tasks had little to no reliable cleanup on several paths) and for a real bug this surfaced and fixed in `navigation.is_on_subscreen`/`return_to_home` itself (a modal open on top of a subscreen was indistinguishable from the true home screen using the header-brightness probe alone — fixed by also checking `is_modal_open`)
- `ba_auto/tasks/mailbox.py` and `ba_auto/tasks/cafe.py` click with `ba_auto/driver.py` primitives and verify state with `driver.color_at` and `ba_auto/navigation.py`
- mailbox and cafe were ported from the reference patterns around `module/mail.py` and `module/cafe_reward.py`
@ -680,6 +680,7 @@ Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, ev
- `ba_auto/tasks/story_sweep.py` sweeps a config-driven list of exact `(region, stage, count)` targets from `config.STORY_SWEEP_TARGETS`, plus one daily-rotating target (`config.STORY_SWEEP_ROTATION_*`) that cycles through a fixed region's stages one per day
- story sweep navigates via OCR, using region-number readout and stage-label matching, rather than random selection
- see `plan.md` Phase 10 for the OCR-based story sweep port, and its "Phase 10 follow-up" for the rotation-target fix
- `story_sweep.py` also has its own campaign-active guard (`story_sweep_force` to override), an explicit Normal-tab click-and-verify (needed now that `story_sweep_hard` can run earlier in the same preset and leave Hard tab selected), and the same `_confirm_dialog_is_sweep`/self-heal-navigation/no-false-negative-MAX-verify fixes `story_sweep_hard.py` needed — see `plan.md` Phase 22 for the full rewrite, live-tested successfully on the first real attempt (a real MAX sweep of the day's rotation target, correctly detected as swept)
- `ba_auto/tasks/story_sweep_hard.py` sweeps a fixed, user-supplied priority-ordered list of Hard-mode `(region, stage)` targets (`config.HARD_STORY_SWEEP_TARGETS`) via the in-game MAX button (capped at 3x by the game itself), reusing story_sweep.py's region-nav and stage-info-modal machinery directly (confirmed pixel-identical) but with its own fixed 3-row stage list (no scrolling/OCR-label search — Hard always has exactly missions 1-3) and its own campaign-active guard: it refuses to spend any AP unless the pink "キャンペーン中" reward-campaign banner is showing on the region-info card, unless run via the separate `story_sweep_hard_force` command
- a real gold-button money hazard exists in this same modal for Hard specifically: a stage that already used all 3 of today's auto-sweep clears still shows a clickable 入場 button, and attempting to sweep it raises a real "spend 40 Pyroxene/blue gems to refill today's clear count?" dialog, not just an AP-insufficient prompt — guarded by two independent layers (an OCR pre-check on the modal's own count field before ever clicking MAX, plus an OCR fallback on the dialog's own text, both declining via Escape rather than any positional click)
- see `plan.md` Phase 21 for the full live-testing writeup, including the navigation-cascade bug, the false-negative MAX-click abort bug, and the gem-refill hazard, all found and fixed against the real game

View File

@ -196,6 +196,18 @@ WORK_ICON = (1793, 1138)
# screenshot, not just a hunch. (1250, 380) sits solidly mid-card, on the
# "任務" title text itself, well clear of every edge.
TASK_CARD = (1250, 380)
# Normal/Hard toggle tab atop the region browser's stage-list panel.
# story_sweep.py explicitly clicks+verifies this (mirroring
# story_sweep_hard.py's own HARD_TAB/_open_hard_tab) rather than assuming
# Normal is always the screen's default -- added 2026-07-21 once
# story_sweep_hard.py started running earlier in the same q4h preset
# sequence and could plausibly leave Hard selected. NORMAL_TAB_ACTIVE_PROBE
# is sampled clear of the "Normal" glyph itself (dark navy background,
# confirmed live 2026-07-21); note this is a DIFFERENT active-tab color than
# Hard's own red (HARD_TAB_ACTIVE_RGB below) -- each tab has its own accent.
NORMAL_TAB = (1200, 297)
NORMAL_TAB_ACTIVE_PROBE = (1100, 297)
NORMAL_TAB_ACTIVE_RGB = ((20, 40, 60), (100, 120, 140))
REGION_RIGHT_ARROW = (1862, 598)
# Pixel-scanline-scanned (not visually estimated -- see plan.md Phase 8's
# lesson) from scratchpad/stage_info.png: the "<" chevron's navy-blue pixel
@ -295,6 +307,16 @@ STAGE_MODAL_CLOSE_BUTTON = (1691, 225)
# yet; confirm it before relying on a non-"max" configured count (see
# plan.md Phase 10).
SWEEP_PLUS_BUTTON = (1520, 550)
# "MIN" stepper button -- forces a known baseline of 1 before applying "+"
# raises for a configured exact (non-"max") count. Added 2026-07-21: the
# stepper remembers its last-used value across opens (confirmed live via
# bounty.py's own real overspend incident, see plan.md Phase 15 and
# CLAUDE.md's stepper-default guidance), so blindly clicking "+" count-1
# times on top of an unknown starting value could under- or over-shoot the
# intended count. Same row/layout as SWEEP_MAX_BUTTON/SWEEP_PLUS_BUTTON,
# pixel-scanned directly (confirmed live, the leftmost button in the
# MIN/-/count/+/MAX row).
SWEEP_MIN_BUTTON = (1180, 555)
# Clicking 掃討開始 (start sweep) always raises an "AP<N>使用して、掃討を
# <M>回行いますか?" usage-confirmation dialog before the sweep actually
@ -419,27 +441,33 @@ HARD_STAGE_ENTER_PROBE_HALF_SIZE = (25, 15)
HARD_COUNT_FIELD_RECT = (1338, 535, 1474, 575)
HARD_CHALLENGE_ALERT_TEXT_RECT = (600, 385, 1330, 470)
# Hard-only reward campaign banner, rendered directly on the region-info card
# (left panel) whenever a reward campaign is active for Hard tasks --
# confirmed live 2026-07-20 on both Area 30 and Area 29 with the exact text
# "任務Hardで獲得できる報酬量が2倍(+100%)になっています。", so this reads as
# account-wide, not per-region. No reference equivalent exists at all
# Reward campaign banner, rendered directly on the region-info card (left
# panel) whenever a reward campaign is active for whichever tab (Normal or
# Hard) is currently selected -- confirmed live both times: "任務Hardで獲得
# できる報酬量が2倍(+100%)になっています。" (2026-07-20, Area 30/29) and
# "任務Normalで獲得できる報酬量が2倍(+100%)になっています。" (2026-07-21,
# Area 30), pixel-identical rect/color in both cases, only the tab name in
# the text differs. Reads as account-wide, not per-region (same text on
# multiple regions each time). No reference equivalent exists at all
# (baas-reference has no campaign/drop-rate concept anywhere) -- per explicit
# user direction, this task refuses to spend AP unless this banner is
# showing, unless explicitly overridden (see story_sweep_hard.py's
# force= parameter / the story_sweep_hard_force CLI command). The home
# screen's own smaller "キャンペーン中" badge (top-right notice stack) was
# considered but rejected as the actual gate signal: it can point at any of
# several concurrent campaigns (e.g. a totally unrelated "大決戦開催中"
# banner sits right above it), not necessarily Hard task rewards
# specifically, while this in-panel banner explicitly names "任務Hard".
# user direction, both story_sweep.py and story_sweep_hard.py refuse to spend
# AP unless this banner is showing for their own tab, unless explicitly
# overridden (force= parameter / the story_sweep_force / story_sweep_hard_force
# CLI commands). The home screen's own smaller "キャンペーン中" badge (top-right
# notice stack) was considered but rejected as the actual gate signal: it can
# point at any of several concurrent campaigns (e.g. a totally unrelated
# "大決戦開催中" banner sits right above it), not necessarily story-task
# rewards specifically, while this in-panel banner explicitly names the tab.
# Detected via a flat color-presence scan (not OCR) since the pink chip is
# clean and high-contrast and the banner is either fully rendered or entirely
# absent (never a different overlapping color) -- matching this project's
# own established convention for unambiguous binary state signals (e.g.
# gem_shop.py's claimed/unclaimed probe) rather than OCR'ing the banner text.
HARD_CAMPAIGN_BADGE_RECT = (164, 426, 344, 464)
HARD_CAMPAIGN_BADGE_RGB = ((230, 100, 160), (256, 210, 256))
# Named generically (not HARD_-prefixed) since both story_sweep.py and
# story_sweep_hard.py share it -- originally added for Hard only, renamed
# 2026-07-21 once story_sweep.py's own campaign check reused it unchanged.
TASK_CAMPAIGN_BADGE_RECT = (164, 426, 344, 464)
TASK_CAMPAIGN_BADGE_RGB = ((230, 100, 160), (256, 210, 256))
# (region, stage) priority-ordered targets to sweep, in the exact order given
# by the user (highest farming priority first, not region-ascending) --

File diff suppressed because one or more lines are too long

View File

@ -27,16 +27,65 @@ constants.
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
MAX_BUTTON_RETRIES = 3
MODAL_CLOSE_RETRIES = 3
REGION_READ_RETRIES = 3
def _is_stage_modal_open(driver, config):
@ -65,6 +114,25 @@ def _is_ap_purchase_prompt(driver, config):
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
@ -74,15 +142,6 @@ def _find_result_button(driver, config):
return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN)
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 a 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)
@ -102,8 +161,49 @@ def _open_task_screen(driver, config):
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):
return detector.read_int(config.REGION_NUMBER_OCR_RECT)
"""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):
@ -206,31 +306,51 @@ def _find_stage_row(driver, config, region, stage):
return None
def _click_max_and_verify(driver, config):
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):
return True
print(f"[story_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _click_min(driver, config):
driver.click(*config.SWEEP_MIN_BUTTON)
driver.wait(0.8)
def _click_plus_and_verify(driver, config, count):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
for _ in range(count - 1):
driver.click(*config.SWEEP_PLUS_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[story_sweep] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
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":
return _click_max_and_verify(driver, config)
return _click_plus_and_verify(driver, config, count)
_click_max(driver, config)
else:
_click_plus(driver, config, count)
def _close_stage_modal(driver, config):
@ -246,25 +366,28 @@ def _close_stage_modal(driver, config):
def _watch_sweep_result(driver, config):
# The reference's start_sweep returns one of "inadequate_ap",
# "charge_challenge_counts", or "sweep_complete" so its caller reacts
# appropriately -- this ports that same named-outcome contract via
# navigation.wait_for_state instead of the old single generic "Done".
#
# Called only after the usage-confirm dialog is already accepted (see
# _sweep_target), so from here it's purely "click through the
# 掃討完了 SKIP/OK screens until the bare stage-info modal reappears."
# Clicking the found button by color (not a keypress) means this never
# risks landing on the underlying "任務開始" button the way a blind
# Enter-press loop would.
"""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,
@ -292,18 +415,9 @@ def _sweep_target(driver, config, region, stage, count):
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 "unrecognized_state"
if not _set_sweep_count(driver, config, count):
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 "unrecognized_state"
_set_sweep_count(driver, config, count)
driver.click(*config.SWEEP_START_BUTTON)
driver.wait(1.5)
@ -313,17 +427,22 @@ def _sweep_target(driver, config, region, stage, count):
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
_close_stage_modal(driver, config)
if navigation.is_on_subscreen(driver):
driver.keypress("Escape")
driver.wait(1.5)
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)
if navigation.is_on_subscreen(driver):
driver.keypress("Escape")
driver.wait(1.5)
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)
@ -334,11 +453,6 @@ def _sweep_target(driver, config, region, stage, count):
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
if navigation.is_on_subscreen(driver):
driver.keypress("Escape")
driver.wait(1.5)
return outcome
@ -351,7 +465,7 @@ def _rotation_target(config):
return (region, stage, config.STORY_SWEEP_ROTATION_COUNT)
def run(driver, config):
def run(driver, config, force=False):
driver.focus_game()
targets = list(config.STORY_SWEEP_TARGETS)
@ -368,7 +482,22 @@ def run(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")

View File

@ -17,9 +17,11 @@ always-exactly-3 fixed stage rows with no scrolling/OCR-label search needed
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.
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 --
@ -158,7 +160,7 @@ def _open_hard_tab(driver, config):
def _campaign_active(driver, config):
return detector.region_contains_color(config.HARD_CAMPAIGN_BADGE_RECT, *config.HARD_CAMPAIGN_BADGE_RGB)
return detector.region_contains_color(config.TASK_CAMPAIGN_BADGE_RECT, *config.TASK_CAMPAIGN_BADGE_RGB)
def _ensure_hard_screen(driver, config):

View File

@ -13,9 +13,14 @@ TASKS = {
"gem_shop": gem_shop.run,
"circle": circle.run,
"story_sweep": story_sweep.run,
# Bypasses story_sweep's campaign-active guard -- see that module's
# docstring and config.TASK_CAMPAIGN_BADGE_RECT for why the guard exists
# (no reference equivalent; per explicit user direction, added 2026-07-21
# to match story_sweep_hard's own guard).
"story_sweep_force": lambda d, c: story_sweep.run(d, c, force=True),
"story_sweep_hard": story_sweep_hard.run,
# Bypasses story_sweep_hard's campaign-active guard -- see that module's
# docstring and config.HARD_CAMPAIGN_BADGE_RECT for why the guard exists
# docstring and config.TASK_CAMPAIGN_BADGE_RECT for why the guard exists
# (no reference equivalent; per explicit user direction, Hard sweep
# normally refuses to spend AP without an active reward campaign).
"story_sweep_hard_force": lambda d, c: story_sweep_hard.run(d, c, force=True),
@ -27,15 +32,16 @@ TASKS = {
"bounty": bounty.run,
"exit_game": exit_game.run,
}
# story_sweep, story_sweep_hard (and its story_sweep_hard_force variant),
# event_sweep, both shop tasks, lesson, arena, and bounty are opt-in only
# (not in the default flow): they spend AP/credits/tactical coin/lesson
# tickets/an arena ticket/a bounty ticket on an automated choice rather than
# reclaiming something free, which is a real resource decision the default
# unattended run shouldn't make blindly. story_sweep_hard additionally has
# its own campaign-active guard on top of being opt-in (see that module's
# docstring) -- story_sweep_hard_force is a second, separate opt-in command
# for bypassing that guard, not something DEFAULT_ORDER/PRESETS should ever
# story_sweep (and its story_sweep_force variant), story_sweep_hard (and its
# story_sweep_hard_force variant), event_sweep, both shop tasks, lesson,
# arena, and bounty are opt-in only (not in the default flow): they spend
# AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket on
# an automated choice rather than reclaiming something free, which is a real
# resource decision the default unattended run shouldn't make blindly. Both
# story_sweep and story_sweep_hard additionally have their own campaign-active
# guard on top of being opt-in (see each module's own docstring) --
# story_sweep_force/story_sweep_hard_force are separate opt-in commands for
# bypassing those guards, not something DEFAULT_ORDER/PRESETS should ever
# invoke unattended. Arena specifically
# fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's
# module docstring. gem_shop and circle are the opposite case -- like
@ -77,7 +83,8 @@ PRESETS = {
"gem_shop", "mailbox", "stamina", "event_sweep", "exit_game",
],
"q4h": [
"login", "cafe", "mailbox", "stamina", "story_sweep_hard", "event_sweep", "exit_game"
"login", "cafe", "mailbox", "stamina", "story_sweep_hard",
"event_sweep", "story_sweep", "exit_game"
]
}

27
plan.md
View File

@ -1003,6 +1003,33 @@ Implemented `ba_auto/tasks/story_sweep_hard.py` and `config.HARD_STORY_SWEEP_TAR
**Net real result across the session**: multiple genuine Hard sweeps confirmed for real -- H18-3 and H30-3 both fully exhausted their daily 3/3 clears (60 AP each), and six more targets (H28-3, H17-3, H13-3, H23-2, H16-1, H20-3) swept for real in the final run before AP ran low. Zero AP was wasted once the false-negative MAX-click bug was fixed. Zero gems were ever spent despite deliberately reproducing the real-money hazard twice for calibration. By the final run, all eight already-exhausted-today targets (H18-3, H30-3, H17-2, H15-2, H14-3, H10-3, H14-2, H7-1) correctly self-skipped via the new pre-check without ever reaching the gem-refill dialog.
### Phase 22: story_sweep.py rewrite to match story_sweep_hard.py's Phase 21 fixes, plus its own campaign guard (2026-07-21)
Direct follow-up to Phase 21, per explicit user direction: "story_sweep was implemented long time ago so it might have old/buggy code. Try to match the hard implementation on best practices." This module predates story_sweep_hard.py's own live debugging and had several of the same latent bugs, since story_sweep_hard.py had copied its patterns verbatim in the first place.
The user also asked for a campaign-active guard on Normal mode, matching Hard's -- Normal mode was confirmed to be under its own real 2x reward campaign at the time.
Live investigation on nik-gpu (2026-07-21) before making any change confirmed:
- The reward-campaign banner is pixel-identical (same rect, same color) between the Normal and Hard tabs -- only the tab name in the text differs ("任務Normalで獲得できる報酬量が2倍(+100%)になっています" vs Hard's own "任務Hard..."). `config.HARD_CAMPAIGN_BADGE_RECT`/`_RGB` were renamed to the generic `config.TASK_CAMPAIGN_BADGE_RECT`/`_RGB` and are now shared by both modules rather than duplicated.
- Normal mode's own tabbed stage-info modal has the identical two-stacked-action-button hazard (gold 任務開始 directly beneath cyan 掃討開始) that `config.py`'s own `SWEEP_MAX_BUTTON` comment already documented for this exact layout, but `story_sweep.py` had never actually added an OCR gate against it (unlike bounty.py/story_sweep_hard.py, both of which have `_confirm_dialog_is_sweep`).
- Normal mode has no per-stage daily clear-count cap at all -- no "残り回数" indicator anywhere in its modal, confirming the Hard-specific gem-refill hazard (Phase 21, item 3) genuinely does not apply here; nothing else needed on that front.
- The Normal/Hard toggle tab needed its own explicit guard for a new reason Hard never had to consider: with `story_sweep_hard` now able to run earlier in the same `q4h` preset sequence (the user's own recent edit), it can leave the Hard tab selected, and `story_sweep.py` had always implicitly assumed Normal was already active.
Rewrote `ba_auto/tasks/story_sweep.py`, porting every fix from Phase 21 even though most had never actually been triggered in production (this module's own `config.STORY_SWEEP_TARGETS` has always been empty, so historically only one rotation target ever ran per invocation -- never enough iterations for a multi-target bug to surface):
- `_open_normal_tab`/`_ensure_task_screen` (new): explicit Normal-tab click-and-verify, plus a loop-level self-heal check before each target, replacing the removed per-path "if on subscreen, press Escape" cleanup -- the same latent bug Phase 21 found and fixed in story_sweep_hard.py, copied here originally, never triggered here only because of the single-target-per-run history above.
- `_click_max`/`_click_plus` (renamed from `_click_max_and_verify`/`_click_plus_and_verify`): dropped the "must visibly raise above the floor" abort gate, same false-negative class Phase 21 found under low AP.
- `_click_plus` also gained a `_click_min` reset-to-baseline step (new `config.SWEEP_MIN_BUTTON`) before applying "+" raises -- porting bounty.py's own real fix (plan.md Phase 15) for a stepper that remembers its last-used value across opens. This module's original `_click_plus_and_verify` never did this; a latent version of the exact bug bounty.py hit for real, just never exercised since no configured target has ever used a non-"max" count in practice.
- `_confirm_dialog_is_sweep` (new): OCR gate against the two-stacked-button hazard, reusing `config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT` directly (confirmed live to crop this modal's own confirm text correctly too).
- `_watch_sweep_result` gained the same `clicked_any`-gated second `ends` condition.
- `_read_current_region` gained the same `REGION_READ_RETRIES` retry-on-`None` wrapper.
- `_campaign_active` (new): the actual feature request, using the shared `config.TASK_CAMPAIGN_BADGE_RECT`/`_RGB`. `run()` gained a `force=False` parameter; `story_sweep_force` was added to `ba_daily.py`'s `TASKS` as the override command, mirroring `story_sweep_hard_force`.
`config.py` additions: `NORMAL_TAB`/`NORMAL_TAB_ACTIVE_PROBE`/`NORMAL_TAB_ACTIVE_RGB` (live-calibrated 2026-07-21 -- Normal's own active-tab color is dark navy, a different accent than Hard's red), `SWEEP_MIN_BUTTON` (live-calibrated against the same MIN/-/+/MAX row as the existing MAX/PLUS/MINUS-probe constants).
**Live-tested for real the same session**: a real run swept the day's rotation target (30-2) end-to-end successfully on the first attempt -- campaign guard correctly passed (banner was genuinely showing), region navigation succeeded, the OCR stage-row search matched correctly despite the same known leading-digit misread already documented in this module ("20-2" was read for the "30-2" row, but `_label_suffix`'s suffix-only comparison still matched on "2"), the sweep-usage confirm dialog passed both the color and the new OCR text gate, and the result was correctly detected as `"swept"` (not the old cosmetic misreport story_sweep_hard.py hit) on the very first real attempt. AP dropped from ~139 to 1 and credits rose by 13,966, confirming a real, substantial MAX sweep. Clean return to home confirmed by screenshot. No new bugs found -- unlike Phase 21, this rewrite worked correctly the first time it touched the real game, likely because every fix ported in was already proven live in story_sweep_hard.py rather than being newly speculative here.
## Prerequisites
### OCR