feat(event_sweep): implement event AP sweep functionality with live calibration
This commit is contained in:
parent
a1806b421e
commit
1b313d27ff
@ -198,6 +198,110 @@ STORY_SWEEP_ROTATION_REGION = 30
|
||||
STORY_SWEEP_ROTATION_STAGE_COUNT = 6
|
||||
STORY_SWEEP_ROTATION_COUNT = "max"
|
||||
|
||||
# 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-
|
||||
# string sweep-list parsing (preprocess_activity_region/
|
||||
# preprocess_activity_sweep_times, e.g. "9,10,11" stages x "0.5,3,1/3"
|
||||
# counts), this sweeps exactly one stage per run, chosen from a fixed
|
||||
# sub-range of the current event's stage list via the same date-ordinal-
|
||||
# modulo rotation as STORY_SWEEP_ROTATION_* (see story_sweep.py's
|
||||
# _rotation_target docstring for why that's a plain date ordinal modulo, not
|
||||
# day-of-year).
|
||||
#
|
||||
# Live-calibrated against nik-gpu on 2026-07-10 against the currently-running
|
||||
# "鉄道爆走事件" event (12 stages, all already 3-starred on this account) --
|
||||
# see scratchpad/event_probe_* for the captured screenshots this was
|
||||
# pixel-scanned/OCR-tested against. Zero real AP was spent during calibration
|
||||
# (every confirm dialog reached was cancelled via Escape, verified by the AP
|
||||
# counter being unchanged before/after).
|
||||
|
||||
# Home screen's top-right event countdown badge/thumbnail (e.g. "終了まで
|
||||
# あと11日"). Confirmed live as the only reliable entry point: the bottom-left
|
||||
# banner slot cycles between several unrelated banners (gacha pickups, other
|
||||
# campaigns) and does not consistently reach the current story event.
|
||||
EVENT_BADGE_ICON = (1787, 300)
|
||||
EVENT_BACK_BUTTON = (85, 55)
|
||||
|
||||
# Top-right tab bar inside the event screen (reference's activity_menu
|
||||
# story/mission/challenge tabs -- rendered as English labels "Story / Quest /
|
||||
# Challenge" even on this JP client). "Quest" is this client's rendering of
|
||||
# the reference's "mission" tab, the one that hosts the sweep-able stage
|
||||
# list. Clicked explicitly every run rather than trusting the screen's
|
||||
# default landing tab (it happened to default here during calibration, but
|
||||
# nothing guarantees that for a future event).
|
||||
EVENT_QUEST_TAB = (1416, 165)
|
||||
|
||||
# Stage list, scrolled to its bottom extreme. Confirmed live this always
|
||||
# reveals the event's LAST 5 stages at these exact row positions regardless
|
||||
# of starting scroll state -- unlike story_sweep's region stage list, row
|
||||
# height here stayed constant across both 1-line and 2-line wrapped titles
|
||||
# during calibration, so no separate top/bottom row-position sets were
|
||||
# needed for this task's stages-9-12 target range.
|
||||
EVENT_STAGE_LIST_SCROLL_POINT = (1400, 700)
|
||||
EVENT_STAGE_LIST_SCROLL_CLICKS = 25
|
||||
EVENT_STAGE_ROW_Y = (366, 538, 710, 883, 1055)
|
||||
EVENT_STAGE_ENTER_X = 1698
|
||||
|
||||
# Stage-number OCR crop (e.g. "09", "12"), offset from a row's known center
|
||||
# y -- rect = (x1, row_y - top_pad, x2, row_y + bottom_pad). Pixel-scanned
|
||||
# live across all 5 bottom-extreme row positions; tight enough to exclude
|
||||
# the 3-star rating row rendered just below each number.
|
||||
EVENT_STAGE_NUMBER_OCR_X = (1055, 1145)
|
||||
EVENT_STAGE_NUMBER_OCR_Y_PAD = (30, 5)
|
||||
|
||||
# 任務情報 (stage info) modal. Corner probe is pixel-identical to
|
||||
# story_sweep's STAGE_MODAL_PROBE (same dark-backdrop dialog component) but
|
||||
# kept as its own constant to avoid coupling the two tasks' config together.
|
||||
EVENT_STAGE_MODAL_PROBE = (1870, 600)
|
||||
EVENT_STAGE_MODAL_DIM_MAX_CHANNEL = 150
|
||||
|
||||
# Sweep sub-panel. Unlike story_sweep, this modal has ONE fixed layout --
|
||||
# confirmed identical across both stages tested live (09 and 12), no
|
||||
# tabbed-vs-plain variant to account for.
|
||||
EVENT_SWEEP_MAX_BUTTON = (1631, 511)
|
||||
EVENT_SWEEP_PLUS_BUTTON = (1517, 511)
|
||||
# The "-" stepper button's fill: flat grey while count is still at its
|
||||
# default of 1, a distinctly saturated coral/orange once MAX (or any "+")
|
||||
# has raised it -- confirmed live (171,172,171) vs (251,173,152). Told apart
|
||||
# by color *spread* (max-min channel) rather than story_sweep's simpler
|
||||
# r>200-and-g<180 rule, since this button's raised color isn't as vividly
|
||||
# orange.
|
||||
EVENT_SWEEP_MINUS_BUTTON_PROBE = (1281, 511)
|
||||
EVENT_SWEEP_START_BUTTON = (1400, 668)
|
||||
|
||||
# Modal close. Confirmed live this modal DOES close on Escape (unlike
|
||||
# story_sweep's, which doesn't -- see STAGE_MODAL_CLOSE_BUTTON above); the
|
||||
# X button position is kept as a fallback if Escape ever doesn't clear it.
|
||||
EVENT_STAGE_MODAL_CLOSE_BUTTON = (1704, 271)
|
||||
|
||||
# The AP-usage confirmation dialog raised by 掃討開始, and the post-sweep
|
||||
# "掃討完了" SKIP/OK result screen, reuse SWEEP_CONFIRM_BUTTON/
|
||||
# SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD/
|
||||
# SWEEP_RESULT_BUTTON_REGION directly (defined above under story_sweep) --
|
||||
# confirmed live pixel-identical position and color, since it's the same
|
||||
# shared dialog component both tasks reach after their own 掃討開始 click.
|
||||
|
||||
# Rotation target: which of stages 9-12 to sweep today, per explicit user
|
||||
# request (2026-07-10) -- "the current event has up to 12 stages, randomly
|
||||
# choose stage 9-12, same mod%4 date method as story sweep." Same
|
||||
# date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*, just over this
|
||||
# sub-range instead of 1..N. Set EVENT_SWEEP_ROTATION_STAGE_MIN to None to
|
||||
# disable.
|
||||
#
|
||||
# NOT yet live-confirmed: what the stage-info modal looks like for a stage
|
||||
# that has never been cleared to SSS -- the reference's own activity_sweep
|
||||
# gates the sweep panel on check_sweep_availability()=="sss" and falls back
|
||||
# to a manual fight otherwise. Every stage 9-12 on this account was already
|
||||
# 3-starred during calibration, so that gate was never actually exercised.
|
||||
# event_sweep.py handles this the same defensive way story_sweep handles an
|
||||
# unavailable region/stage: if the MAX-button click can't be verified to
|
||||
# have raised the sweep count, it aborts that target without spending AP
|
||||
# rather than guessing.
|
||||
EVENT_SWEEP_ROTATION_STAGE_MIN = 9
|
||||
EVENT_SWEEP_ROTATION_STAGE_MAX = 12
|
||||
EVENT_SWEEP_ROTATION_COUNT = "max"
|
||||
|
||||
# Common Shop / Tactical Challenge Shop. Both tabs share the same underlying
|
||||
# checkbox-grid-then-bulk-buy UI (the live equivalent of the reference's
|
||||
# module/shop/shop_utils.py get_item_position/ensure_choose/buy pattern);
|
||||
|
||||
@ -8,6 +8,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...`
|
||||
| Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect` | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger |
|
||||
| Stamina/AP | `module/collect_daily_free_power.py`, `module/collect_daily_task_power.py` | `to_tasks`/`implement` (task-power, ported); `to_purchase_pyroxenes_menu` (free-power, not ported) | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Partially migrated: Mission-panel claim done (see `plan.md` Phase 8). Daily Free Power (real-money purchase menu) deliberately not automated |
|
||||
| Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; MAX click verified via `SWEEP_MINUS_BUTTON_PROBE` color check (reused, still correct), modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow |
|
||||
| Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py` | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event countdown badge (`config.EVENT_BADGE_ICON`); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done. Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). NOT yet live-tested with a real sweep/AP spend, and the reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. See `plan.md`'s Event sweep phase |
|
||||
| Group/Club AP | `module/group.py` | Need to inspect | `ba_auto/tasks/group.py` | fixed click + state check via local driver | Not started |
|
||||
| Bounty | `module/rewarded_task.py` | Need to inspect | `ba_auto/tasks/bounty.py` | sweep/color/OCR adaptation | Not started |
|
||||
| Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started |
|
||||
|
||||
252
ba_auto/tasks/event_sweep.py
Normal file
252
ba_auto/tasks/event_sweep.py
Normal file
@ -0,0 +1,252 @@
|
||||
"""Event AP sweep. Reference: baas-reference/module/sweep_activity.py and
|
||||
module/activities/activity_utils.py's activity_sweep/start_sweep.
|
||||
|
||||
Ported per explicit user direction (2026-07-10): the currently-running event
|
||||
("鉄道爆走事件") exposes 12 stages via a 任務情報 (task info) modal that is
|
||||
structurally identical to story_sweep.py's own stage modal (MIN/-/+/MAX
|
||||
count stepper, the same AP-usage confirm dialog, the same 掃討完了 SKIP/OK
|
||||
result screen) -- see config.py's "Event sweep" section for the full live
|
||||
calibration writeup. Rather than porting the reference's config-string
|
||||
sweep-list parsing (arbitrary stage lists with per-stage float/fraction
|
||||
counts), this sweeps exactly one stage per run, chosen from a fixed 9-12
|
||||
sub-range via the same date-ordinal-modulo rotation story_sweep.py already
|
||||
uses for its own daily-rotating target.
|
||||
|
||||
Key differences from story_sweep.py, all confirmed live during calibration:
|
||||
- No region concept/navigation -- one flat stage list, reached via the home
|
||||
screen's event badge -> Quest tab, not story_sweep's Work-hub card ->
|
||||
region browser.
|
||||
- The stage list only ever needs its bottom scroll extreme, which always
|
||||
reveals stages 9-12 (this event's last 5 stages) regardless of starting
|
||||
scroll position -- no separate top/bottom row-position sets needed.
|
||||
- Row height is constant regardless of 1-line vs 2-line title wrapping,
|
||||
unlike story_sweep's stage list.
|
||||
- The stage modal has ONE fixed layout (confirmed against both stage 09 and
|
||||
12), no tabbed-vs-plain variant to account for.
|
||||
- The stage modal DOES close on Escape, unlike story_sweep's (X-button-only).
|
||||
- Both stages tested were already 3-starred; the reference's SSS-availability
|
||||
gate for never-cleared stages was never actually exercised (see config.py).
|
||||
"""
|
||||
import datetime
|
||||
|
||||
from ba_auto import detector, navigation
|
||||
|
||||
|
||||
OPEN_RETRIES = 3
|
||||
POST_SWEEP_DISMISS_ROUNDS = 6
|
||||
MAX_BUTTON_RETRIES = 3
|
||||
MODAL_CLOSE_RETRIES = 3
|
||||
|
||||
|
||||
def _is_stage_modal_open(driver, config):
|
||||
r, g, b = driver.color_at(*config.EVENT_STAGE_MODAL_PROBE)
|
||||
return r < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.EVENT_STAGE_MODAL_DIM_MAX_CHANNEL
|
||||
|
||||
|
||||
def _color_in_range(rgb, rgb_range):
|
||||
lo, hi = rgb_range
|
||||
r, g, b = rgb
|
||||
return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2]
|
||||
|
||||
|
||||
def _is_sweep_usage_confirm(driver, config):
|
||||
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 _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):
|
||||
# This modal's "-" stepper turns a saturated coral/orange once raised,
|
||||
# a subtler shift than story_sweep's vivid-orange indicator -- told
|
||||
# apart from its flat-grey default by color *spread* (max-min channel)
|
||||
# rather than a fixed g/b ceiling. See config.py's EVENT_SWEEP_MINUS_
|
||||
# BUTTON_PROBE comment.
|
||||
r, g, b = driver.color_at(*config.EVENT_SWEEP_MINUS_BUTTON_PROBE)
|
||||
return (max(r, g, b) - min(r, g, b)) > 40
|
||||
|
||||
|
||||
def _open_event_screen(driver, config):
|
||||
for attempt in range(1, OPEN_RETRIES + 1):
|
||||
driver.click(*config.EVENT_BADGE_ICON)
|
||||
driver.wait(2)
|
||||
if navigation.is_on_subscreen(driver):
|
||||
break
|
||||
print(f"[event_sweep] event screen not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
||||
else:
|
||||
return False
|
||||
|
||||
driver.click(*config.EVENT_QUEST_TAB)
|
||||
driver.wait(1)
|
||||
return True
|
||||
|
||||
|
||||
def _row_number_rect(config, row_y):
|
||||
x1, x2 = config.EVENT_STAGE_NUMBER_OCR_X
|
||||
top_pad, bottom_pad = config.EVENT_STAGE_NUMBER_OCR_Y_PAD
|
||||
return (x1, row_y - top_pad, x2, row_y + bottom_pad)
|
||||
|
||||
|
||||
def _find_stage_row(driver, config, stage):
|
||||
# This event's target range (9-12) always sits within the last 5 rows,
|
||||
# confirmed live regardless of starting scroll position -- so unlike
|
||||
# story_sweep._find_stage_row, only the bottom extreme is ever checked.
|
||||
x, y = config.EVENT_STAGE_LIST_SCROLL_POINT
|
||||
driver.scroll(x, y, "down", config.EVENT_STAGE_LIST_SCROLL_CLICKS)
|
||||
driver.wait(0.5)
|
||||
|
||||
for row_y in config.EVENT_STAGE_ROW_Y:
|
||||
label = detector.read_int(_row_number_rect(config, row_y))
|
||||
print(f"[event_sweep] row @ {row_y}: read '{label}'")
|
||||
if label == stage:
|
||||
return row_y
|
||||
return None
|
||||
|
||||
|
||||
def _click_max_and_verify(driver, config):
|
||||
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
|
||||
driver.click(*config.EVENT_SWEEP_MAX_BUTTON)
|
||||
driver.wait(0.8)
|
||||
if _count_raised_above_one(driver, config):
|
||||
return True
|
||||
print(f"[event_sweep] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
|
||||
return False
|
||||
|
||||
|
||||
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.EVENT_SWEEP_PLUS_BUTTON)
|
||||
driver.wait(0.8)
|
||||
if _count_raised_above_one(driver, config):
|
||||
return True
|
||||
print(f"[event_sweep] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _close_stage_modal(driver, config):
|
||||
# Confirmed live this modal DOES close on Escape (unlike story_sweep's,
|
||||
# which needs its own X button) -- try Escape first, fall back to the X
|
||||
# button if it somehow doesn't clear.
|
||||
for _ in range(MODAL_CLOSE_RETRIES):
|
||||
if not _is_stage_modal_open(driver, config):
|
||||
return True
|
||||
driver.keypress("Escape")
|
||||
driver.wait(1)
|
||||
if not _is_stage_modal_open(driver, config):
|
||||
return True
|
||||
driver.click(*config.EVENT_STAGE_MODAL_CLOSE_BUTTON)
|
||||
driver.wait(1)
|
||||
return not _is_stage_modal_open(driver, config)
|
||||
|
||||
|
||||
def _watch_sweep_result(driver, config):
|
||||
def click_result_button(d):
|
||||
pos = _find_result_button(d, config)
|
||||
if pos:
|
||||
d.click(*pos)
|
||||
d.wait(1.5)
|
||||
|
||||
ends = {
|
||||
(lambda d, c: _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, stage, count):
|
||||
print(f"[event_sweep] --- target stage {stage} x {count} ---")
|
||||
|
||||
row_y = _find_stage_row(driver, config, stage)
|
||||
if row_y is None:
|
||||
print(f"[event_sweep] stage {stage} not found in the visible stage list")
|
||||
return "stage_not_found"
|
||||
|
||||
driver.click(config.EVENT_STAGE_ENTER_X, row_y)
|
||||
driver.wait(2)
|
||||
|
||||
if not _is_stage_modal_open(driver, config):
|
||||
print("[event_sweep] stage info panel not detected, aborting")
|
||||
return "unrecognized_state"
|
||||
|
||||
if not _set_sweep_count(driver, config, count):
|
||||
print("[event_sweep] could not confirm sweep count was raised (stage may not be SSS-cleared/sweepable yet) -- aborting without spending AP")
|
||||
_close_stage_modal(driver, config)
|
||||
return "not_sweepable"
|
||||
|
||||
driver.click(*config.EVENT_SWEEP_START_BUTTON)
|
||||
driver.wait(1.5)
|
||||
|
||||
if _is_ap_purchase_prompt(driver, config):
|
||||
print("[event_sweep] insufficient AP for this sweep -- cancelling without purchasing")
|
||||
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
|
||||
driver.wait(1)
|
||||
_close_stage_modal(driver, config)
|
||||
return "inadequate_ap"
|
||||
|
||||
if not _is_sweep_usage_confirm(driver, config):
|
||||
print("[event_sweep] sweep-usage confirmation not detected, aborting without further input")
|
||||
_close_stage_modal(driver, config)
|
||||
return "unrecognized_state"
|
||||
|
||||
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
||||
driver.wait(1.5)
|
||||
print("[event_sweep] sweep confirmed, waiting for results")
|
||||
outcome = _watch_sweep_result(driver, config)
|
||||
print(f"[event_sweep] result: {outcome}")
|
||||
|
||||
if not _close_stage_modal(driver, config):
|
||||
print("[event_sweep] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
|
||||
return outcome
|
||||
|
||||
|
||||
def _rotation_target(config):
|
||||
stage_min = getattr(config, "EVENT_SWEEP_ROTATION_STAGE_MIN", None)
|
||||
if not stage_min:
|
||||
return None
|
||||
stage_max = config.EVENT_SWEEP_ROTATION_STAGE_MAX
|
||||
span = stage_max - stage_min + 1
|
||||
stage = stage_min + (datetime.date.today().toordinal() % span)
|
||||
return (stage, config.EVENT_SWEEP_ROTATION_COUNT)
|
||||
|
||||
|
||||
def run(driver, config):
|
||||
driver.focus_game()
|
||||
|
||||
rotation = _rotation_target(config)
|
||||
if not rotation:
|
||||
print("[event_sweep] no rotation target configured (config.EVENT_SWEEP_ROTATION_STAGE_MIN is unset), nothing to do")
|
||||
return
|
||||
stage, count = rotation
|
||||
print(f"[event_sweep] today's rotation target: stage {stage}")
|
||||
|
||||
if not _open_event_screen(driver, config):
|
||||
print("[event_sweep] could not confirm event screen is open, aborting without pressing further keys")
|
||||
return
|
||||
|
||||
outcome = _sweep_target(driver, config, stage, count)
|
||||
if outcome != "swept":
|
||||
print(f"[event_sweep] target stage {stage} ended in '{outcome}'")
|
||||
|
||||
if navigation.is_on_subscreen(driver):
|
||||
driver.click(*config.EVENT_BACK_BUTTON)
|
||||
driver.wait(1)
|
||||
|
||||
print("[event_sweep] Done.")
|
||||
13
ba_daily.py
13
ba_daily.py
@ -3,23 +3,24 @@
|
||||
import sys
|
||||
|
||||
from ba_auto import config, driver
|
||||
from ba_auto.tasks import arena, cafe, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep
|
||||
from ba_auto.tasks import arena, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep
|
||||
|
||||
TASKS = {
|
||||
"mailbox": mailbox.run,
|
||||
"cafe": cafe.run,
|
||||
"stamina": stamina.run,
|
||||
"story_sweep": story_sweep.run,
|
||||
"event_sweep": event_sweep.run,
|
||||
"shop_common": shop_common.run,
|
||||
"shop_tactical": shop_tactical.run,
|
||||
"lesson": lesson.run,
|
||||
"arena": arena.run,
|
||||
}
|
||||
# story_sweep, both shop tasks, lesson, and arena are opt-in only (not in
|
||||
# the default flow): they spend AP/credits/tactical coin/lesson tickets/an
|
||||
# arena 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 fights a real ranked PvP
|
||||
# story_sweep, event_sweep, both shop tasks, lesson, and arena are opt-in
|
||||
# only (not in the default flow): they spend AP/credits/tactical coin/lesson
|
||||
# tickets/an arena 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 fights a real ranked PvP
|
||||
# battle each run -- see ba_auto/tasks/arena.py's module docstring.
|
||||
DEFAULT_ORDER = ["mailbox", "cafe", "stamina"]
|
||||
|
||||
|
||||
26
plan.md
26
plan.md
@ -158,6 +158,8 @@ Do not implement a feature without filling at least the relevant row.
|
||||
| Normal/Hard story AP sweep | Done (Phase 10, supersedes Phase 9's random-pick design): `ba_auto/tasks/story_sweep.py` sweeps a config-driven list of exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), navigating to each via OCR (region-number read + delta-click, stage-label OCR match) instead of "latest region, random stage." Opt-in only (`story_sweep` command), not part of the default daily flow | Done |
|
||||
| Common Shop / Tactical Shop | Done (Phase 11): `ba_auto/tasks/shop_common.py` / `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, price-OCR-verified before each click. Live-tested with real purchases in both shops. Opt-in only (`shop_common`/`shop_tactical` commands), not part of the default daily flow | Done |
|
||||
| Lesson/Schedule | Done (Phase 12): `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid, picking the highest-affection available lesson each time via a dedicated heart-badge OCR read (`detector.read_int_on_heart_badge`) until tickets or lessons run out. Live-tested with real tickets spent; two real bugs (checkmark-doesn't-blank-the-number, badge OCR misreads) found and fixed. Opt-in only (`lesson` command), not part of the default daily flow | Done |
|
||||
| Arena / Tactical Challenge | Done (Phase 13): `ba_auto/tasks/arena.py` fights exactly one ranked battle per invocation and collects both reward slots. Live-tested across all 5 of the account's daily tickets (2 WIN, 1 LOSE); the post-fight WIN/LOSE result modal proved undetectable by precise button-color search and was fixed via a bounded blind-Enter loop gated by a hard safety check. Opt-in only (`arena` command), not part of the default daily flow | Done |
|
||||
| Event sweep | Implemented (Phase 14), calibrated live with zero real AP spent, not yet live-tested with a real sweep: `ba_auto/tasks/event_sweep.py` sweeps one config-rotated stage (9-12) of the currently-running event per invocation, reusing story_sweep's AP-confirm/result-screen config directly since the underlying dialog is pixel-identical. Opt-in only (`event_sweep` command), not part of the default daily flow | Needs a real live sweep test |
|
||||
| Shared driver | `ba_auto/driver.py` built (`run_command`, `focus_game`, `click`, `move_mouse`, `scroll`, `keypress`, `screenshot`, `wait`, `color_at`); `click()` now splits `mousemove`/`click` into two xdotool calls (Phase 8 finding — fixes a real source of click flakiness); wired into `mailbox.py`, `cafe.py`, `stamina.py`, `story_sweep.py`, `shop_common.py`, `shop_tactical.py`, `lesson.py` | Extend with new primitives as future tasks need them |
|
||||
| Python CLI | Built: `ba_daily.py` dispatches `mailbox`/`cafe`/`stamina`/`story_sweep`/`shop_common`/`shop_tactical`/`lesson`/default flow | Extend as new tasks are added |
|
||||
| Reference mapping | Built: `ba_auto/reference_notes/mapping.md` | Fill in reference file/function columns per feature |
|
||||
@ -464,6 +466,22 @@ This is the same class of finding CLAUDE.md already documents from mailbox/cafe'
|
||||
|
||||
**Not verified**: the "no ticket" popup race the reference guards against (`get_tickets` going stale between the initial read and the attack-formation click — this account's tickets never hit exactly 0 mid-flow during testing); `choose_enemy`'s actual reroll click (`config.ARENA_REFRESH_LIST_BUTTON` is wired up per the reference's logic, but every opponent offered during testing was already an acceptable level, so a real reroll was never triggered); `ARENA_STOP_FIGHT_WHEN_RANK1`'s rank-1 stopping condition (default `False`, and this account's rank never got close enough to test the branch). All three are implemented per the reference's own logic, not guessed at, but none has been exercised against real game state yet.
|
||||
|
||||
### Phase 14: Event sweep
|
||||
|
||||
**Status: Implemented, calibrated live against zero real AP spend, NOT yet live-tested with a real sweep.** Ported `module/sweep_activity.py` -> `module/activities/activity_utils.py`'s `activity_sweep`/`start_sweep` to `ba_auto/tasks/event_sweep.py`, per explicit user request (2026-07-10): "the current event has up to 12 stages, randomly choose stage 9-12, same mod%4 date method as story sweep."
|
||||
|
||||
**Scope decision**: rather than porting the reference's config-string sweep-list parsing (arbitrary stage lists with per-stage float/fraction AP-fraction counts, e.g. `"9,10,11"` x `"0.5,3,1/3"`), this sweeps exactly one stage per run, chosen from a fixed 9-12 sub-range via the same date-ordinal-modulo rotation `story_sweep.py`'s `_rotation_target` already uses (a plain `date.today().toordinal() % span`, not day-of-year, so the cycle doesn't skip/repeat around a year boundary) — matching the user's own framing of the request.
|
||||
|
||||
**Live calibration, against the currently-running "鉄道爆走事件" (12 stages, all already 3-starred on this account), with zero real AP spent** (every AP-usage confirm dialog reached during calibration was cancelled via Escape, verified by the top-bar AP counter being unchanged before and after):
|
||||
|
||||
- **Entry point**: the home screen's top-right event countdown badge/thumbnail (`config.EVENT_BADGE_ICON`), not the bottom-left banner slot — that slot was found live to cycle between several unrelated banners (gacha pickups, other campaigns) across screenshots taken seconds apart, and clicking it landed on a *different, already-concluded* event once. The event list icon (イベント一覧) was also checked and ruled out: its "進行中" tab lists side campaigns (schedule-reward bonus, joint firepower drill, etc.), not the main story event.
|
||||
- **The stage-info modal is structurally identical to story_sweep's**: same MIN/-/+/MAX count stepper, the same AP-usage confirm dialog (reused directly — `config.SWEEP_CONFIRM_BUTTON`/`SWEEP_CONFIRM_CANCEL_BUTTON`/`SWEEP_CONFIRM_CYAN`/`SWEEP_CONFIRM_GOLD` are pixel-identical live), and the same `SWEEP_RESULT_BUTTON_REGION` SKIP/OK result screen — expected, since the reference's own `activity_utils.py::start_sweep` is a near-duplicate of `sweep_task.py::start_sweep`, reusing the same underlying image names.
|
||||
- **Simpler than story_sweep in three ways**, all confirmed live: no region concept (one flat stage list reached via the event's own Quest tab, not a Work-hub-card region browser); the stage list's bottom scroll extreme always reveals stages 9-12 regardless of starting scroll position, with **constant row height** regardless of 1-line vs 2-line title wrapping (no separate top/bottom row-position sets needed, unlike story_sweep's); and the modal has **one fixed layout** confirmed identical across two different stages tested (09 and 12) — no tabbed-vs-plain variant.
|
||||
- **One behavior difference from story_sweep found live**: this modal *does* close on Escape (confirmed: AP counter and screen state both correct after Escape-cancelling the confirm dialog and closing the stage modal), whereas story_sweep's needs its own X button. `event_sweep.py` tries Escape first and falls back to the X button.
|
||||
- **The "-" stepper's raised-count color signal is subtler here** than story_sweep's vivid-orange: a saturated coral `(251,173,152)` vs flat grey `(171,172,171)`, told apart by channel spread (`max-min > 40`) rather than story_sweep's simpler `r>200 and g<180` rule.
|
||||
|
||||
**Not yet exercised**: an actual real sweep/AP spend (calibration deliberately avoided this, per `CLAUDE.md`'s "validate offline before spending the resource" guidance — the confirm dialog, cyan/gold color distinction, and result-screen mechanics were all visually confirmed but never clicked through to completion); the reference's SSS-availability gate for a stage that has never been cleared (every stage 9-12 on this account was already 3-starred, so `check_sweep_availability`'s "not yet sweepable, needs a manual fight first" branch was never seen — `event_sweep.py` handles this the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts that target without spending AP rather than guessing what an unsweepable stage's modal looks like); behavior once this event ends 2026-07-22 and a future event's badge/stage-count/layout replaces it (the 9-12 rotation range and row-position calibration are specific to this 12-stage event, not proven durable across arbitrary future events).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### OCR
|
||||
@ -782,6 +800,8 @@ Convenience only.
|
||||
|
||||
### Event content
|
||||
|
||||
**Status: Event AP sweep done — see Phase 14.** The user asked for exactly the generic-sweep case anticipated below (reusing story_sweep's AP-confirm/result-screen logic, not a prebuilt event-specific script), scoped to the currently-running "鉄道爆走事件" event's 9-12 stage range via a rotation target.
|
||||
|
||||
Reference:
|
||||
|
||||
```
|
||||
@ -789,11 +809,7 @@ Reference:
|
||||
~/repo/baas-reference/module/sweep_activity.py
|
||||
```
|
||||
|
||||
Do not prebuild specific event scripts.
|
||||
|
||||
Build only generic event AP sweep if it can reuse story sweep logic.
|
||||
|
||||
Event-specific content expires and is not worth hardcoding unless the user asks for a specific live event.
|
||||
Still not built: anything beyond the sweep panel (`explore_activity_story`/`explore_activity_mission`/`explore_activity_challenge` — walking the event's map/fighting story stages manually, needed only for a stage that hasn't been SSS-cleared yet) and the reward-exchange shop (`exchange_reward`). Event-specific content expires; the 9-12 rotation range and row-position calibration are specific to this 12-stage event, not proven durable across future events with a different stage count or layout.
|
||||
|
||||
## Skip list
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user