ba-auto-daily/ba_auto/tasks/scrimmage.py
Nik Afiq a690e90478 Implement Scrimmage Automation (学園交流会)
- 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.
2026-08-02 11:12:25 +09:00

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.")