feat: Implement hard story AP sweep functionality

- 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.
This commit is contained in:
Nik Afiq 2026-07-21 00:09:10 +09:00
parent f2387958fb
commit 0e3f2d951c
6 changed files with 605 additions and 10 deletions

View File

@ -665,12 +665,12 @@ When debugging on `nik-gpu`, copy relevant screenshots or debug images back into
## Existing features
Current project state: mailbox, cafe, stamina, story_sweep, event_sweep, shop_common, shop_tactical, lesson, arena, and bounty are all migrated to real Python. No Bash feature logic remains.
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`, `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_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`, `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`, `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
- 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,9 @@ Current project state: mailbox, cafe, stamina, story_sweep, event_sweep, shop_co
- `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
- `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
- `ba_auto/tasks/shop_common.py` and `ba_auto/tasks/shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets (`config.COMMON_SHOP_TARGETS` / `config.TACTICAL_SHOP_TARGETS`)
- item identification is by fixed grid position, not per-item OCR — the reference's own `get_item_position` indexes an external static price table this repo doesn't have, so a locally pixel-scanned position table is the faithful port, not an OCR-avoidance shortcut; price-digit OCR is layered on top as an extra catalog-drift safety check the reference doesn't even do per-item
- both shops were live-tested with real purchases (see `plan.md` Phase 11), which also surfaced a real, previously-unknown per-refresh-cycle purchase cap on these items (not shown as a visible counter) — the task correctly detected the now-unselectable items and safely declined rather than misfiring

View File

@ -346,6 +346,114 @@ STORY_SWEEP_ROTATION_REGION = 30
STORY_SWEEP_ROTATION_STAGE_COUNT = 6
STORY_SWEEP_ROTATION_COUNT = "max"
# Hard story AP sweep (module/explore_tasks/sweep_task.py's sweep_hard_task).
# ba_auto/tasks/story_sweep_hard.py reuses story_sweep's region-nav constants
# (REGION_LEFT_ARROW/RIGHT_ARROW, REGION_NUMBER_OCR_RECT,
# REGION_NAV_MAX_ATTEMPTS, WORK_ICON, TASK_CARD, STAGE_ENTER_X) and its
# stage-info modal constants (STAGE_MODAL_PROBE, SWEEP_MAX_BUTTON,
# SWEEP_MINUS_BUTTON_PROBE, SWEEP_PLUS_BUTTON, SWEEP_START_BUTTON,
# STAGE_MODAL_CLOSE_BUTTON, SWEEP_CONFIRM_BUTTON/_CANCEL_BUTTON/_CYAN/_GOLD,
# SWEEP_RESULT_BUTTON_REGION) directly -- confirmed live 2026-07-20 these are
# pixel-identical between Normal and Hard's shared modal component (same
# underlying UI widget). Only what's genuinely different about Hard gets its
# own constants below.
# Normal/Hard toggle tab atop the region browser's stage-list panel (opened
# via WORK_ICON -> TASK_CARD, same entry point as story_sweep.py). Live-
# calibrated 2026-07-20: clicking this stays selected across region
# navigation -- no need to re-click after every REGION_LEFT_ARROW/
# RIGHT_ARROW click, unlike the reference's own to_hard_event/to_normal_event
# re-assertion after every region move (that reference behavior exists
# because uiautomator2 has no persistent notion of "which tab is active"
# across a fresh screenshot the way this project's own state checks do).
HARD_TAB = (1600, 297)
# Active-tab red background, sampled clear of the "Hard" glyph itself, to
# verify the click actually landed (matching this project's established
# click-then-verify pattern) rather than trusting a single blind click.
HARD_TAB_ACTIVE_PROBE = (1750, 297)
HARD_TAB_ACTIVE_RGB = ((150, 0, 0), (230, 110, 110))
# Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A"
# bonus stage, confirmed against the reference's own
# explore_hard_task_region_range/available_missions=range(1,4)) and never
# needs scrolling -- unlike Normal's up-to-6-row scrolling list, so no
# OCR-based row search is needed, just a direct index by mission number.
# Row-center y values pixel-scanned live 2026-07-20 (cyan-button-color scan
# against scratchpad/hard_sweep_05_hard_tab.png, Area 30). Reuses
# STAGE_ENTER_X (defined above under story_sweep) for the row's own 入場
# button x -- confirmed identical.
HARD_STAGE_ROWS_Y = (435, 605, 775)
# The row's own 入場 (enter) button reuses SWEEP_CONFIRM_CYAN's color range
# (defined above under story_sweep) to tell an unlocked row (clickable,
# button rendered in the same cyan as every other clickable button in this
# project) from a locked one (greyed out) -- ports the reference's own
# to_mission_info locked-button check, live-calibrated color instead of the
# reference's own fixed rgb_in_range values (different screen layout).
HARD_STAGE_ENTER_PROBE_HALF_SIZE = (25, 15)
# Real-money hazard, confirmed live 2026-07-20: a Hard stage that has already
# used all 3 of today's auto-sweep clears still shows a clickable (cyan)
# 入場 button -- HARD_STAGE_ENTER_PROBE_HALF_SIZE's unlocked-vs-locked check
# alone does NOT catch this. Opening its stage-info modal shows the sweep
# count field itself already defaulted to "0" (a healthy stage defaults to
# "1"), and clicking 掃討開始 with count=0 raises a completely different
# "アラート" dialog ("Hard <region>-<stage>の挑戦回数が不足しています。挑戦回数
# を回復させますか?", "あと1回回復可能", 消費する青輝石: 40 -- i.e. spend 40
# Pyroxene/blue gems to refill the daily clear count) with a GOLD OK button
# that sits close enough to SWEEP_CONFIRM_BUTTON's position that a careless
# reuse of that single-point probe could misread it, but is confirmed
# live to NOT actually match either SWEEP_CONFIRM_CYAN or _GOLD at that
# exact pixel (this dialog's own button sits a few px off), so a naive
# _is_sweep_usage_confirm/_is_ap_purchase_prompt check alone would silently
# fall through to "sweep-usage confirmation not detected, aborting" -- safe,
# but relying on a coincidence rather than an actual check. Two independent
# guards, matching this project's established defense-in-depth pattern for
# gold-button hazards (see bounty.py, and story_sweep_hard.py's own
# _confirm_dialog_is_sweep): (1) HARD_COUNT_FIELD_RECT is checked BEFORE ever
# clicking MAX/掃討開始, so a stage already at 0 remaining is skipped without
# reaching this dialog at all; (2) HARD_CHALLENGE_ALERT_TEXT_RECT OCR-detects
# the dialog by its own text as a fallback, declining via Escape (its own
# labeled ESC/キャンセル action) rather than any positional click, in case
# the count somehow changes between the pre-check and the click (e.g. a
# second concurrent session).
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
# (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".
# 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))
# (region, stage) priority-ordered targets to sweep, in the exact order given
# by the user (highest farming priority first, not region-ascending) --
# unlike STORY_SWEEP_TARGETS these are plain (region, stage) pairs with no
# per-target count: every target always uses MAX, capped at 3x by the game
# itself (see the modal's own "残り回数N/3" counter, discovered live), so
# there's nothing to configure per-target beyond which stage. Stage is always
# 1-3 (Hard has no "-A" bonus stage, see HARD_STAGE_ROWS_Y above).
HARD_STORY_SWEEP_TARGETS = [
(18, 3), (30, 3), (27, 3), (28, 3), (17, 3), (13, 3), (23, 2),
(16, 1), (20, 3), (17, 2), (15, 2), (14, 3), (10, 3), (14, 2),
(7, 1), (5, 3), (4, 1),
]
# Event sweep (module/sweep_activity.py -> module/activities/<current_event>.py
# -> module/activities/activity_utils.py's activity_sweep). Ported per
# explicit user direction 2026-07-10: rather than the reference's config-

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,442 @@
"""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.")

View File

@ -3,7 +3,7 @@
import sys
from ba_auto import config, driver, navigation
from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep
from ba_auto.tasks import arena, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep, story_sweep_hard
TASKS = {
"login": login.run,
@ -13,6 +13,12 @@ TASKS = {
"gem_shop": gem_shop.run,
"circle": circle.run,
"story_sweep": story_sweep.run,
"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
# (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),
"event_sweep": event_sweep.run,
"shop_common": shop_common.run,
"shop_tactical": shop_tactical.run,
@ -21,11 +27,16 @@ TASKS = {
"bounty": bounty.run,
"exit_game": exit_game.run,
}
# story_sweep, 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. Arena specifically
# 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
# 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
# mailbox/cafe/stamina, they only ever reclaim a genuinely free (0 yen),

32
plan.md
View File

@ -973,6 +973,36 @@ Confirmed via direct inspection on nik-gpu right after the crash that nothing wa
Fixed with the same one-line defensive pattern `click_back` already uses for its own `focus_game()` call: check `driver.window_exists()` again right at the escalation point, returning `False` instead of crashing if the window is already gone by then. This is a general robustness fix to shared navigation cleanup, not something specific to `exit_game` -- any task whose cleanup runs while the game window is disappearing (not just a deliberate `exit_game` close) could in principle hit the same race. Deployed; not yet re-confirmed against a fresh real `exit_game`-then-cleanup sequence (would need another real `q4h`/`daily` fire that includes `exit_game`).
### Phase 21: Hard story AP sweep (2026-07-20/21)
User request: sweep a fixed, priority-ordered list of Hard-mode story stages (18-3, 30-3, 27-3, 28-3, 17-3, 13-3, 23-2, 16-1, 20-3, 17-2, 15-2, 14-3, 10-3, 14-2, 7-1, 5-3, 4-1), gated by an active drop-rate/reward campaign, with an override command to bypass the gate.
Reference: `module/explore_tasks/sweep_task.py`'s `sweep_hard_task`, `task_utils.py`'s `to_hard_event`/`to_region`. The reference has NO concept of a drop-rate campaign anywhere in the codebase (confirmed via a full-repo `grep`), so the campaign guard is genuinely new local logic, not a port -- everything else follows the reference's own control flow.
Live investigation (2026-07-20) on nik-gpu found:
- The Normal/Hard toggle sits atop the same region-browser screen `story_sweep.py` already opens (`WORK_ICON` -> `TASK_CARD`), and stays selected across region navigation -- unlike the reference's own per-region-move `to_hard_event` re-assertion (unnecessary here since this project's state checks work differently from uiautomator2's).
- Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A" bonus, no scrolling ever needed) -- simpler than Normal's scroll+OCR row search, matching the reference's own `available_missions = range(1, 4)`.
- The stage-info modal is pixel-identical to Normal's own (all of `SWEEP_MAX_BUTTON`/`SWEEP_START_BUTTON`/`STAGE_MODAL_CLOSE_BUTTON`/confirm-dialog constants reused directly, confirmed via a real zero-AP-spent dry run of the confirm dialog).
- A pink "キャンペーン中" banner renders directly on the region-info card whenever a Hard-task reward campaign is active ("任務Hardで獲得できる報酬量が2倍(+100%)になっています"), confirmed account-wide (identical text on regions 30 and 29) -- chosen as the gate signal over the home screen's own smaller badge, which can point at unrelated concurrent campaigns (a totally unrelated "大決戦開催中" banner sits right above it).
- The bounty.py two-stacked-action-button hazard (cyan 掃討開始 directly above gold 任務開始, a real manual battle, both showing an identical AP-cost preview) is present in this modal too -- ported bounty's own `_confirm_dialog_is_sweep` OCR text-gate proactively here instead of waiting for a live near-miss to prove it's needed.
Implemented `ba_auto/tasks/story_sweep_hard.py` and `config.HARD_STORY_SWEEP_TARGETS`/`HARD_TAB`/`HARD_STAGE_ROWS_Y`/`HARD_CAMPAIGN_BADGE_RECT` (and related constants). CLI dispatch added as `story_sweep_hard` plus a separate `story_sweep_hard_force` command to bypass the campaign guard -- both opt-in only, not in `DEFAULT_ORDER` or either preset.
**Live-tested for real across three rounds on nik-gpu, several real bugs found and fixed:**
1. **Navigation-cascade bug.** Every `_sweep_target` exit path pressed Escape "if still on subscreen" as leftover-dialog cleanup, copied from story_sweep.py -- but the region browser itself satisfies `is_on_subscreen`, so this fired after literally every target (success or abort) and backed all the way out to the Work hub, breaking navigation for every subsequent target in the priority list. Confirmed live via a direct single-Escape-press test against the region browser. story_sweep.py has the identical latent bug but never hit it in production, since its own `STORY_SWEEP_TARGETS` is empty by default (only the single daily rotation target ever runs, so its loop never reaches a second iteration where the drift would surface). Fixed by dropping the per-path Escape presses entirely and centralizing recovery in a new `_ensure_hard_screen` self-heal check at the top of each loop iteration in `run()`, mirroring `ba_daily.py`'s own `_ensure_home`.
2. **False-negative MAX-click abort.** `_click_max_and_verify` required the sweep count to visibly rise above 1 as proof the click landed. Once AP drops under 40 (Hard costs 20 AP/hit, capped at 3x), MAX legitimately caps at 1x, which is visually indistinguishable from "click didn't register" (the same grey stepper). This wrongly aborted 11 consecutive real, affordable targets in a single run without even attempting them. Fixed by dropping the verification gate (renamed `_click_max`) -- the downstream `_is_ap_purchase_prompt`/`_is_sweep_usage_confirm`/`_confirm_dialog_is_sweep` checks are the real safety net regardless of what the stepper visually showed.
3. **Real-money hazard, found and fixed before any gems were spent.** A Hard stage that already used all 3 of today's auto-sweep clears still shows a clickable (cyan) 入場 button on the region-browser row -- the existing locked-row color check does not catch this. Opening its stage-info modal shows the sweep-count field itself already defaulted to "0" (a healthy stage defaults to "1"), and clicking 掃討開始 with count=0 raises a completely different gold-button "アラート" dialog: "Hard \<region\>-\<stage\>の挑戦回数が不足しています。挑戦回数を回復させますか?" / "あと1回回復可能" / 消費する青輝石: 40 -- i.e. spend 40 real Pyroxene/blue gems to refill today's clear count. Deliberately reproduced to calibrate (zero gems spent, confirmed unchanged before/after by screenshot, declined via the dialog's own labeled ESC/キャンセル action). Fixed with two independent layers, matching this project's established defense-in-depth pattern for gold-button hazards: `_remaining_sweeps_today` OCRs the count field BEFORE ever clicking MAX/掃討開始 and skips the target outright (`"daily_limit_reached"`) if it reads 0; `_is_challenge_count_alert` OCR-detects the dialog by its own text as a fallback and declines via Escape (never a positional click) in case the count changes between the check and the click.
4. **Cosmetic `"unrecognized_state"` misreport.** Every real sweep in the second live run showed `result: unrecognized_state` despite the sweep genuinely completing (AP spent, subsequent targets proceeded normally afterward) -- widening `POST_SWEEP_DISMISS_ROUNDS` (6 -> 10) did not fix it, proving this was never a timing issue. Root cause: the same terminal-state gap `event_sweep.py` hit historically (see that module's Phase 14 follow-up #5) -- Hard's post-sweep flow can land all the way back on the bare region browser instead of leaving the 任務情報 modal open, which `_watch_sweep_result`'s original "swept" condition required. Ported `event_sweep.py`'s own `clicked_any`-gated second `ends` condition directly.
5. **Region-navigation flakiness, mitigated but not fully root-caused.** Navigating from region 30 to region 27 failed identically ("gave up after 8 attempts") across two separate real runs. A follow-up investigation found a live OCR read of the region-number crop fail on a screenshot later confirmed, by eye, to be a perfectly clean and legible single digit -- pointing at a transient render/settle race rather than a genuinely broken crop or calibration. Added a short retry (`REGION_READ_RETRIES`) to `_read_current_region` rather than failing outright on a single `None` read. Not yet independently re-confirmed live (account AP was down to 6 by this point in the session, not enough for further real-sweep testing) -- worth rechecking on a future run with AP available.
**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.
## Prerequisites
### OCR
@ -1081,7 +1111,7 @@ OCR: Not expected.
### 4. Normal/Hard story AP sweep
**Status: Done — see Phase 10 (supersedes Phase 9's random-pick design).**
**Status: Done — Normal mode see Phase 10 (supersedes Phase 9's random-pick design); Hard mode see Phase 21 (`ba_auto/tasks/story_sweep_hard.py`, separate task module + CLI command, own campaign-active guard).**
Sweep already-cleared main story stages to burn AP.