- Added new task for Scrimmage (学園交流会) in `ba_auto/tasks/scrimmage.py`, allowing automated clearing of the hardest stage (D) in three areas (トリニティ, ゲヘナ, ミレニアム) based on date-ordinal rotation. - Introduced AP cost verification to ensure sweeps only occur when the cost is confirmed as 0 due to the user's monthly pass. - Updated `ba_daily.py` to include the new scrimmage task in the daily preset and task registry. - Documented the implementation details and testing outcomes in `plan.md` and `mapping.md`. - Conducted live tests confirming successful execution and correct handling of ticket and AP costs.
405 lines
18 KiB
Python
405 lines
18 KiB
Python
"""Scrimmage / 学園交流会. Reference: baas-reference/module/scrimmage.py.
|
|
|
|
Ported per explicit user direction (2026-08-02): 3 areas (トリニティ/ゲヘナ/
|
|
ミレニアム -- this client's rendering of the reference's own
|
|
Trinity/Gehenna/Millennium scrimmage_area_name list), choose one via the
|
|
same date-ordinal-modulo rotation story_sweep.py/event_sweep.py/bounty.py
|
|
already use, and always sweep that area's hardest (D, last-lettered) stage.
|
|
|
|
Differs from the reference's own control flow (module/scrimmage.py's
|
|
get_los/scrimmage_common_operation, which scans however many stage rows are
|
|
currently visible for the first one whose color.check_sweep_availability
|
|
reads "sss") the same way bounty.py already differs from its own reference
|
|
(module/rewarded_task.py's analogous get_los/one_detect scan): rather than a
|
|
color-based per-row SSS-availability scan across a scrollable list, this
|
|
client's Academy Select stage list is a small FIXED 4-row grid (01 A / 02 B
|
|
/ 03 C / 04 D) with no scrolling at all -- confirmed live identical between
|
|
Trinity and Gehenna, all 4 rows already 3-starred/SSS-cleared -- closer to
|
|
story_sweep_hard.py's fixed 3-row Hard-mode list than to bounty's
|
|
scroll-to-extreme design. Per explicit user direction, the hardest
|
|
(last-lettered) row is always targeted directly (config.
|
|
SCRIMMAGE_HARDEST_STAGE_INDEX), with no availability scan needed.
|
|
|
|
Genuinely new guard, no reference equivalent: the account has a real
|
|
monthly pass making every scrimmage sweep cost 0 AP (ticket-only), per
|
|
explicit user direction. Rather than assume the pass is always active,
|
|
_sweep_hardest_stage OCR-reads the AP cost the ticket-usage confirm dialog
|
|
itself states in plain text ("学園交流会チケットをN、APをM使用して、掃討を
|
|
N回行いますか?") and refuses to click the final confirm button unless that
|
|
reads exactly 0, cancelling instead -- see config.
|
|
SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT for the live-captured dialog text this
|
|
was calibrated against.
|
|
|
|
Live-recon'd against nik-gpu 2026-08-02, zero real tickets spent (the one
|
|
ticket-usage confirm dialog reached during recon was cancelled via Escape,
|
|
ticket count 30/6 confirmed unchanged before/after):
|
|
|
|
- Entry: WORK_ICON -> config.SCRIMMAGE_CARD (学園交流会 card, third card in
|
|
the Work hub's left column, below 指名手配/特別依頼) lands on Academy
|
|
Select. Same card-based entry pattern as bounty's 指名手配 card / arena's
|
|
Tactical Challenge card / story_sweep's Task card.
|
|
- Academy Select's 3 area rows are a fixed list, not scrollable --
|
|
config.SCRIMMAGE_AREA_ROW_Y indexes directly by rotation, same pattern as
|
|
config.BOUNTY_AREA_ROW_Y.
|
|
- Each area's own stage list (reached by clicking an area row) is a fixed
|
|
4-row grid, confirmed live identical layout between Trinity and Gehenna
|
|
(same 入場 (enter) button x/y per row). config.SCRIMMAGE_STAGE_ROW_Y
|
|
indexes directly by config.SCRIMMAGE_HARDEST_STAGE_INDEX (3, i.e. row 04,
|
|
stage D) -- no OCR search needed, unlike story_sweep/event_sweep's
|
|
longer-than-visible lists.
|
|
- The 任務情報 (task info) modal reached by clicking a stage's 入場 button
|
|
is PIXEL-IDENTICAL in position/color to bounty.py's own stage-info modal
|
|
(same MIN/-/+/MAX stepper coordinates, same
|
|
(171,172,171)-vs-(251,173,152) raised/default minus-button colors, same
|
|
cyan 掃討開始 button position) -- confirmed live. It has the SAME real
|
|
hazard bounty.py's own config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT comment
|
|
documents at length: a second, gold 任務開始 (start mission, a REAL
|
|
manual battle) button directly below the intended cyan one. This module
|
|
never clicks anywhere near config.
|
|
SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK, and additionally gates the
|
|
final confirm click on an OCR text check (_confirm_dialog_is_sweep,
|
|
mirroring bounty.py's own) before ever committing.
|
|
- The ticket-usage confirm dialog reached after 掃討開始 is the same shared
|
|
"通知" component SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/
|
|
SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD already cover (confirmed live
|
|
pixel-identical OK/Cancel button positions to bounty's own dialog) --
|
|
reused directly. Its own message text is genuinely new here: unlike any
|
|
other sweep-style dialog in this project, it states its own AP cost in
|
|
plain digits, which is exactly what the AP==0 guard above reads.
|
|
- The post-sweep result screen was NOT reached live (every dialog was
|
|
cancelled before confirming, to spend zero real tickets during recon) --
|
|
config.SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION is seeded from bounty.py's
|
|
own already-hardened region rather than the generic
|
|
SWEEP_RESULT_BUTTON_REGION story_sweep/event_sweep use, since bounty's
|
|
own history is a direct, documented warning against that generic region
|
|
on this exact modal family (see that config constant's own comment) --
|
|
NOT yet independently confirmed against this module's own real result
|
|
screen.
|
|
|
|
NOT yet live-tested with a real ticket spend -- config.SCRIMMAGE_SWEEP_COUNT
|
|
is "max" (spend every held ticket in one run) per explicit user direction,
|
|
same default bounty.py's own BOUNTY_SWEEP_COUNT uses. Needs the user's
|
|
go-ahead before the first real run, matching this project's own convention
|
|
for newly-added resource-spending tasks (arena, bounty).
|
|
"""
|
|
import datetime
|
|
import re
|
|
|
|
from ba_auto import detector, navigation
|
|
|
|
OPEN_RETRIES = 3
|
|
AREA_RETRIES = 3
|
|
STAGE_ENTER_RETRIES = 3
|
|
MAX_BUTTON_RETRIES = 3
|
|
MODAL_CLOSE_RETRIES = 3
|
|
SWEEP_START_RETRIES = 3
|
|
POST_SWEEP_DISMISS_ROUNDS = 14
|
|
|
|
_AP_COST_PATTERN = re.compile(r"AP\D{0,3}(\d+)")
|
|
|
|
|
|
def _is_stage_modal_open(driver, config):
|
|
r, g, b = driver.color_at(*config.SCRIMMAGE_STAGE_MODAL_PROBE)
|
|
return r < config.SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.SCRIMMAGE_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_ticket_purchase_prompt(driver, config):
|
|
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD)
|
|
|
|
|
|
def _read_confirm_dialog_text(config):
|
|
return detector.read_text(config.SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn")
|
|
|
|
|
|
def _confirm_dialog_is_sweep(driver, config):
|
|
"""OCR-verify the confirm dialog is genuinely the sweep-usage confirm,
|
|
not some other cyan-styled confirmation -- mirrors bounty.py's own
|
|
_confirm_dialog_is_sweep against the identical two-stacked-buttons
|
|
hazard documented in config.py's SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_
|
|
CLICK comment. Substring match on "掃討", matching this project's own
|
|
established dialog-classification convention.
|
|
"""
|
|
return "掃討" in _read_confirm_dialog_text(config)
|
|
|
|
|
|
def _confirm_dialog_ap_cost(config):
|
|
"""Extract the AP cost this dialog states in plain text
|
|
("...APをM使用して..."). Returns None if no recognizable AP-cost figure
|
|
is found -- treated as "unknown", not "zero", by the caller (fail
|
|
closed rather than assume the monthly pass is active).
|
|
"""
|
|
match = _AP_COST_PATTERN.search(_read_confirm_dialog_text(config))
|
|
return int(match.group(1)) if match else None
|
|
|
|
|
|
def _find_result_button(driver, config):
|
|
return detector.find_color_centroid(
|
|
config.SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN,
|
|
min_pixels=config.SCRIMMAGE_RESULT_BUTTON_MIN_PIXELS,
|
|
)
|
|
|
|
|
|
def _count_raised_above_one(driver, config):
|
|
# Same coral/orange color-spread check as bounty.py's own
|
|
# _count_raised_above_one -- confirmed live to read the exact same
|
|
# colors here, (171,172,171) default vs (251,173,152) raised.
|
|
r, g, b = driver.color_at(*config.SCRIMMAGE_SWEEP_MINUS_BUTTON_PROBE)
|
|
return (max(r, g, b) - min(r, g, b)) > 40
|
|
|
|
|
|
def _open_scrimmage_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"[scrimmage] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
|
else:
|
|
return False
|
|
|
|
for attempt in range(1, OPEN_RETRIES + 1):
|
|
driver.click(*config.SCRIMMAGE_CARD)
|
|
driver.wait(2)
|
|
if navigation.is_on_subscreen(driver):
|
|
return True
|
|
print(f"[scrimmage] academy select not detected after click (attempt {attempt}/{OPEN_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _open_area(driver, config, area_index):
|
|
row_y = config.SCRIMMAGE_AREA_ROW_Y[area_index]
|
|
for attempt in range(1, AREA_RETRIES + 1):
|
|
driver.click(config.SCRIMMAGE_AREA_ROW_X, row_y)
|
|
driver.wait(2)
|
|
if navigation.is_on_subscreen(driver):
|
|
return True
|
|
print(f"[scrimmage] stage list not detected after clicking area {area_index} (attempt {attempt}/{AREA_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _open_hardest_stage_modal(driver, config):
|
|
index = config.SCRIMMAGE_HARDEST_STAGE_INDEX
|
|
row_y = config.SCRIMMAGE_STAGE_ROW_Y[index]
|
|
for attempt in range(1, STAGE_ENTER_RETRIES + 1):
|
|
driver.click(config.SCRIMMAGE_STAGE_ENTER_X, row_y)
|
|
driver.wait(2)
|
|
if _is_stage_modal_open(driver, config):
|
|
return True
|
|
print(f"[scrimmage] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _click_max_and_verify(driver, config):
|
|
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
|
|
driver.click(*config.SCRIMMAGE_SWEEP_MAX_BUTTON)
|
|
driver.wait(0.8)
|
|
if _count_raised_above_one(driver, config):
|
|
return True
|
|
print(f"[scrimmage] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _click_min_and_verify(driver, config):
|
|
# Force a known baseline before applying "+" clicks -- the stepper
|
|
# remembers its last-used value across opens (confirmed live via
|
|
# bounty.py's own real overspend incident, see that module's docstring)
|
|
# so this can't be skipped even for count == 1.
|
|
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
|
|
driver.click(*config.SCRIMMAGE_SWEEP_MIN_BUTTON)
|
|
driver.wait(0.8)
|
|
if not _count_raised_above_one(driver, config):
|
|
return True
|
|
print(f"[scrimmage] MIN click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _click_plus_and_verify(driver, config, count):
|
|
if not _click_min_and_verify(driver, config):
|
|
print("[scrimmage] could not confirm count was reset to the minimum before raising it -- aborting rather than risk an unverified starting count")
|
|
return False
|
|
if count == 1:
|
|
return True
|
|
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
|
|
for _ in range(count - 1):
|
|
driver.click(*config.SCRIMMAGE_SWEEP_PLUS_BUTTON)
|
|
driver.wait(0.8)
|
|
if _count_raised_above_one(driver, config):
|
|
return True
|
|
print(f"[scrimmage] 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 the X button closes this modal. Escape is tried as a
|
|
# fallback (not independently confirmed against the bare modal during
|
|
# recon -- see config.SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON's comment).
|
|
for _ in range(MODAL_CLOSE_RETRIES):
|
|
if not _is_stage_modal_open(driver, config):
|
|
return True
|
|
driver.click(*config.SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON)
|
|
driver.wait(1)
|
|
if not _is_stage_modal_open(driver, config):
|
|
return True
|
|
driver.keypress("Escape")
|
|
driver.wait(1)
|
|
return not _is_stage_modal_open(driver, config)
|
|
|
|
|
|
def _watch_sweep_result(driver, config):
|
|
# Same clicked_any-gated two-ends-condition pattern bounty.py's own
|
|
# _watch_sweep_result uses, since this modal shares the same underlying
|
|
# "may auto-return past the bare stage modal all the way to the list"
|
|
# risk.
|
|
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_ticket_purchase_prompt(d, c)): "prompted_to_purchase",
|
|
(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 _click_sweep_start_and_verify(driver, config):
|
|
for attempt in range(1, SWEEP_START_RETRIES + 1):
|
|
driver.click(*config.SCRIMMAGE_SWEEP_START_BUTTON)
|
|
driver.wait(1.5)
|
|
if _is_sweep_usage_confirm(driver, config) or _is_ticket_purchase_prompt(driver, config):
|
|
return True
|
|
print(f"[scrimmage] sweep confirm/ticket-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})")
|
|
return False
|
|
|
|
|
|
def _sweep_hardest_stage(driver, config, area_index, count):
|
|
area_name = config.SCRIMMAGE_AREA_NAMES[area_index]
|
|
stage_letter = config.SCRIMMAGE_STAGE_LETTERS[config.SCRIMMAGE_HARDEST_STAGE_INDEX]
|
|
print(f"[scrimmage] --- area {area_index} ({area_name}), stage {stage_letter} x {count} ---")
|
|
|
|
if not _open_area(driver, config, area_index):
|
|
return "area_unavailable"
|
|
|
|
if not _open_hardest_stage_modal(driver, config):
|
|
print("[scrimmage] stage info panel not detected, aborting")
|
|
return "unrecognized_state"
|
|
|
|
if not _set_sweep_count(driver, config, count):
|
|
print("[scrimmage] could not confirm sweep count was raised -- aborting without spending a ticket")
|
|
_close_stage_modal(driver, config)
|
|
return "not_sweepable"
|
|
|
|
if not _click_sweep_start_and_verify(driver, config):
|
|
print("[scrimmage] sweep confirm/ticket-purchase dialog not detected, aborting without further input")
|
|
_close_stage_modal(driver, config)
|
|
return "unrecognized_state"
|
|
|
|
if _is_ticket_purchase_prompt(driver, config):
|
|
print("[scrimmage] insufficient scrimmage tickets for this sweep -- cancelling without purchasing")
|
|
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
|
|
driver.wait(1)
|
|
_close_stage_modal(driver, config)
|
|
return "inadequate_ticket"
|
|
|
|
# Hard safety gate before the one irreversible click in this whole
|
|
# flow -- see this module's docstring and config.py's
|
|
# SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK for the hazard this
|
|
# closes (a real manual-battle button stacked directly below the
|
|
# intended sweep button). _is_sweep_usage_confirm already passed (a
|
|
# color match), but that alone isn't proof this is really the sweep
|
|
# dialog -- verify the actual text before committing.
|
|
if not _confirm_dialog_is_sweep(driver, config):
|
|
print("[scrimmage] 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"
|
|
|
|
# The AP==0 guard the user actually asked for: this dialog states its
|
|
# own AP cost in plain text ("APをM使用して"). Only confirm if that
|
|
# reads exactly 0 (the real monthly pass making every scrimmage sweep
|
|
# ticket-only) -- cancel otherwise, whether the misread cost is a real
|
|
# nonzero value (pass expired/not covering this stage) or an
|
|
# unrecognized OCR result (None), rather than ever risk spending real
|
|
# AP unexpectedly.
|
|
ap_cost = _confirm_dialog_ap_cost(config)
|
|
if ap_cost != 0:
|
|
print(f"[scrimmage] confirm dialog's own AP cost read as {ap_cost!r} (expected exactly 0 via the monthly pass) -- cancelling without spending AP")
|
|
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
|
|
driver.wait(1)
|
|
_close_stage_modal(driver, config)
|
|
return "ap_cost_not_zero"
|
|
|
|
driver.click(*config.SWEEP_CONFIRM_BUTTON)
|
|
driver.wait(1.5)
|
|
print("[scrimmage] sweep confirmed (AP cost verified 0), waiting for results")
|
|
outcome = _watch_sweep_result(driver, config)
|
|
print(f"[scrimmage] result: {outcome}")
|
|
|
|
if outcome == "prompted_to_purchase":
|
|
print("[scrimmage] ticket-purchase prompt detected after the sweep -- cancelling without purchasing")
|
|
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
|
|
driver.wait(1)
|
|
|
|
if not _close_stage_modal(driver, config):
|
|
print("[scrimmage] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
|
|
return outcome
|
|
|
|
|
|
def _rotation_area(config):
|
|
area_count = len(config.SCRIMMAGE_AREA_NAMES)
|
|
return datetime.date.today().toordinal() % area_count
|
|
|
|
|
|
def run(driver, config):
|
|
driver.focus_game()
|
|
# Reused from bounty.py's own fix for the same class of bug: WORK_ICON's
|
|
# click is home-relative, so a prior run left mid-navigation would send
|
|
# it to the wrong place.
|
|
navigation.return_to_home(driver)
|
|
|
|
area_index = _rotation_area(config)
|
|
print(f"[scrimmage] today's rotation target: area {area_index} ({config.SCRIMMAGE_AREA_NAMES[area_index]})")
|
|
|
|
if not _open_scrimmage_screen(driver, config):
|
|
print("[scrimmage] could not confirm scrimmage academy-select screen is open, aborting without pressing further keys")
|
|
navigation.return_to_home(driver)
|
|
return
|
|
|
|
outcome = _sweep_hardest_stage(driver, config, area_index, config.SCRIMMAGE_SWEEP_COUNT)
|
|
if outcome != "swept":
|
|
print(f"[scrimmage] area {area_index} ended in '{outcome}'")
|
|
|
|
if not navigation.return_to_home(driver):
|
|
print("[scrimmage] warning: could not confirm return to home screen")
|
|
|
|
print("[scrimmage] Done.")
|