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.
This commit is contained in:
Nik Afiq 2026-08-02 11:12:25 +09:00
parent 52b10eda3d
commit a690e90478
5 changed files with 565 additions and 18 deletions

View File

@ -1638,3 +1638,128 @@ BATTLE_PASS_MISSION_TAB_MIN_CHANNEL = 245
# screen, same as MISSION_CLAIM_PROBE, so battle_pass.py uses a keypress
# here rather than a coordinate click.
BATTLE_PASS_CLAIM_PROBE = (1720, 1080)
# Scrimmage / 学園交流会 (module/scrimmage.py). 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. Per explicit user direction: 3 areas
# (トリニティ/ゲヘナ/ミレニアム -- matching the reference's own
# Trinity/Gehenna/Millennium), 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 -- unlike bounty, NOT
# "whichever stage happens to be at the scroll extreme": each area's stage
# list is a small fixed 4-row grid (A-D) with no scrolling at all, confirmed
# live across two different areas (Trinity, Gehenna) sharing an identical
# layout, closer to story_sweep_hard.py's fixed 3-row Hard-mode list than to
# bounty's scroll-to-extreme design. Also per explicit user direction: the
# account has a real monthly pass making every scrimmage sweep cost 0 AP
# (ticket-only) -- scrimmage.py's own AP guard (see
# SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT below) refuses to confirm any sweep
# where the confirm dialog's own OCR'd AP cost isn't exactly 0, rather than
# assuming the pass is always active.
# Work-hub card entry point (WORK_ICON, defined above -> this card).
SCRIMMAGE_CARD = (1100, 995)
# Academy Select's 3 fixed area rows -- all 3 fit on screen with no
# scrolling needed, confirmed live identical row/button layout across
# Trinity and Gehenna (Millennium not independently opened during recon,
# assumed identical per bounty.py's own precedent of all 3 areas sharing
# one layout).
SCRIMMAGE_AREA_ROW_Y = (293, 458, 621)
SCRIMMAGE_AREA_ROW_X = 1400
SCRIMMAGE_AREA_NAMES = ("トリニティ", "ゲヘナ", "ミレニアム")
# Each area's own stage list -- a small fixed 4-row grid (01 A / 02 B / 03 C
# / 04 D), no scrolling, confirmed live identical between Trinity and
# Gehenna (same 入場 (enter) button x/y per row, same "already 3-starred"
# state on every row). Row index 3 (D) is always the hardest/last-lettered
# stage, per explicit user direction to always target it regardless of what
# the game currently calls the top difficulty tier.
SCRIMMAGE_STAGE_ROW_Y = (287, 429, 571, 713)
SCRIMMAGE_STAGE_ENTER_X = 1685
SCRIMMAGE_HARDEST_STAGE_INDEX = 3
SCRIMMAGE_STAGE_LETTERS = ("A", "B", "C", "D")
# 任務情報 (task info) modal. Pixel-identical position/color to bounty.py's
# own BOUNTY_SWEEP_MIN_BUTTON/MAX_BUTTON/PLUS_BUTTON/MINUS_BUTTON_PROBE/
# START_BUTTON (confirmed live, including the exact same raised-vs-default
# minus-button colors, (171,172,171) vs (251,173,152)) -- kept as separate
# SCRIMMAGE_-prefixed constants anyway, matching this file's own established
# reasoning for BOUNTY_SWEEP_MIN_BUTTON et al (avoid coupling separate
# tasks' config together even when the underlying shared UI component is
# pixel-identical).
SCRIMMAGE_SWEEP_MIN_BUTTON = (1178, 511)
SCRIMMAGE_SWEEP_MAX_BUTTON = (1631, 511)
SCRIMMAGE_SWEEP_PLUS_BUTTON = (1517, 511)
SCRIMMAGE_SWEEP_MINUS_BUTTON_PROBE = (1281, 511)
SCRIMMAGE_SWEEP_START_BUTTON = (1400, 668)
# Same real hazard bounty.py's own BOUNTY_SWEEP_CONFIRM_TEXT_RECT comment
# documents at length: this modal has a SECOND action button, a gold
# 任務開始 (start mission, a REAL manual battle) directly below the intended
# cyan 掃討開始 (start sweep), confirmed live at (1400, 868) -- never
# clicked during recon, positional-avoidance only (this task never clicks
# anywhere near it).
SCRIMMAGE_MISSION_START_BUTTON_DO_NOT_CLICK = (1400, 868)
# Modal-open probe -- NOT reused from BOUNTY_STAGE_MODAL_PROBE despite the
# same (1850, 1150) coordinate working here too (confirmed live: this
# screen's own classroom-corner art reads (127-178) all channels with the
# modal closed vs (20-55) with it open/a dialog stacked on top, comfortably
# separated by the same style of threshold bounty.py uses) -- kept as its
# own constant per this file's decoupling convention.
SCRIMMAGE_STAGE_MODAL_PROBE = (1850, 1150)
SCRIMMAGE_STAGE_MODAL_DIM_MAX_CHANNEL = 100
# Modal close via its own X button -- confirmed live. Escape was not
# independently verified against the bare stage-info modal during recon
# (unlike bounty's, which confirmed Escape works there too) since the only
# dialog actually dismissed via Escape during recon was the ticket-usage
# CONFIRM dialog stacked on top of it, not the bare modal itself -- kept as
# the primary close mechanism here rather than assumed.
SCRIMMAGE_STAGE_MODAL_CLOSE_BUTTON = (1691, 271)
# The ticket-usage confirm dialog is the exact same shared "通知" dialog
# component SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/
# SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD (defined above under story_sweep)
# already cover -- confirmed live pixel-identical OK/Cancel button
# positions to bounty's own dialog -- reused directly, no new button/color
# constants needed.
#
# Its own message text, however, is genuinely new and load-bearing here:
# unlike every other sweep-style task in this project, this dialog spells
# out the exact AP cost in plain text -- "学園交流会チケットをN、APをM使用し
# て、掃討をN回行いますか?" (captured live 2026-08-02, cancelled via Escape
# before confirming) -- which is exactly the "only run when AP consumption
# is 0" guard the user asked for: OCR this rect and refuse to confirm unless
# the OCR'd M reads exactly 0, cancelling instead (same defensive shape as
# bounty.py's own _confirm_dialog_is_sweep OCR gate against the identical
# two-stacked-buttons hazard documented above). Same rect bounty.py's own
# BOUNTY_SWEEP_CONFIRM_TEXT_RECT uses (605, 505, 1320, 615) -- confirmed
# live via a real cropped screenshot that this exact rect cleanly captures
# both lines of scrimmage's own dialog text too (same shared dialog
# component, just different message content) -- kept as its own constant
# rather than reused directly, matching this file's decoupling convention.
SCRIMMAGE_SWEEP_CONFIRM_TEXT_RECT = (605, 505, 1320, 615)
# Post-sweep result screen region -- NOT independently live-confirmed
# (recon deliberately cancelled every dialog before confirming a real
# sweep, to spend zero real tickets). Seeded from bounty.py's own ALREADY-
# fixed BOUNTY_SWEEP_RESULT_BUTTON_REGION/BOUNTY_RESULT_BUTTON_MIN_PIXELS
# rather than the generic SWEEP_RESULT_BUTTON_REGION story_sweep/
# event_sweep use, since bounty.py's own history is a direct, documented
# warning against that generic region on this exact modal family: its
# first live test used a naive copy of it, which overlapped
# SCRIMMAGE_SWEEP_START_BUTTON's own real cyan pixels (same (1400, 668)
# position/y-range as bounty's 掃討開始) and re-clicked it once tickets hit
# 0, surfacing a real Pyroxene ticket-purchase prompt. This value is
# bounty's own post-fix region, comfortably clear of that y-range by the
# same geometry -- treat as a reasoned starting point, not a confirmed
# calibration, and re-verify against this module's own real result screen
# at first live test.
SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION = (750, 850, 1160, 1010)
SCRIMMAGE_RESULT_BUTTON_MIN_PIXELS = 3000
# MAX, per explicit user direction (2026-08-02): spend every held ticket in
# one run, same default bounty.py's own BOUNTY_SWEEP_COUNT uses.
SCRIMMAGE_SWEEP_COUNT = "max"

File diff suppressed because one or more lines are too long

404
ba_auto/tasks/scrimmage.py Normal file
View File

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

View File

@ -4,7 +4,7 @@ import os
import sys
from ba_auto import config, driver, navigation
from ba_auto.tasks import arena, battle_pass, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, shop_common, shop_tactical, stamina, story_sweep, story_sweep_hard
from ba_auto.tasks import arena, battle_pass, bounty, cafe, circle, event_sweep, exit_game, gem_shop, lesson, login, mailbox, scrimmage, shop_common, shop_tactical, stamina, story_sweep, story_sweep_hard
TASKS = {
"login": login.run,
@ -32,14 +32,20 @@ TASKS = {
"lesson": lesson.run,
"arena": arena.run,
"bounty": bounty.run,
"scrimmage": scrimmage.run,
"exit_game": exit_game.run,
}
# story_sweep (and its story_sweep_force variant), story_sweep_hard (and its
# story_sweep_hard_force variant), event_sweep, both shop tasks, lesson,
# arena, and bounty are opt-in only (not in the default flow): they spend
# AP/credits/tactical coin/lesson tickets/an arena ticket/a bounty ticket on
# an automated choice rather than reclaiming something free, which is a real
# resource decision the default unattended run shouldn't make blindly. Both
# arena, bounty, and scrimmage are opt-in only (not in the default flow):
# they spend AP/credits/tactical coin/lesson tickets/an arena ticket/a
# bounty ticket/a scrimmage ticket on an automated choice rather than
# reclaiming something free, which is a real resource decision the default
# unattended run shouldn't make blindly. scrimmage additionally refuses to
# confirm any sweep whose own confirm dialog doesn't read an OCR'd AP cost
# of exactly 0 (the account's monthly pass is expected to make every
# scrimmage sweep ticket-only) -- see ba_auto/tasks/scrimmage.py's module
# docstring. Both
# story_sweep and story_sweep_hard additionally have their own campaign-active
# guard on top of being opt-in (see each module's own docstring) --
# story_sweep_force/story_sweep_hard_force are separate opt-in commands for
@ -84,7 +90,7 @@ PRESETS = {
"daily": [
"login", "event_sweep", "cafe", "event_sweep", "circle", "lesson",
"arena", "shop_common", "shop_tactical", "event_sweep", "bounty",
"gem_shop", "mailbox", "stamina", "battle_pass", "event_sweep", "exit_game",
"scrimmage", "gem_shop", "mailbox", "stamina", "battle_pass", "event_sweep", "exit_game",
],
# "q4h": [
# "login", "cafe", "mailbox", "stamina", "event_sweep",

34
plan.md
View File

@ -1215,6 +1215,28 @@ All test `.env` files and the throwaway `smoke_test_alert_bogus.log` were delete
**Not yet done**: the user still needs to run `cp .env.example .env` on nik-gpu and fill in the real `ALERT_BRIDGE_API_KEY` themselves (steps 7 above) -- this session deliberately did not create, request, or guess that value. Once that's in place, steps 9-10 (a real `cafe` invocation confirming an actual Discord message arrives, then an unattended real `daily`/`q4h` cron fire doing the same) are the only remaining open items for this phase.
### Phase 27: Scrimmage / 学園交流会 (2026-08-02)
User request: automate clearing Scrimmage (学園交流会, `module/scrimmage.py`) -- 3 areas, each with 4 fixed-difficulty stages (A-D), area chosen randomly by date, always sweep the hardest (D) stage. The account has a real monthly pass making every scrimmage sweep cost 0 AP (ticket-only); the user explicitly asked for a guard that only runs the sweep when the AP consumption is verified to be 0.
Read `module/scrimmage.py` first, per the reference-first rule: `implement` loops all 3 areas by a configured per-area sweep count (`get_task_count`/`rewarded_task_times`-style, no local equivalent), `get_los`/`scrimmage_common_operation` scans a scrollable stage list top-to-bottom via `color.check_sweep_availability`'s per-row SSS-color probe (`src/rgb_feature/JP.json`'s `sideTaskSSS`/`sideTaskNoPass` 3-point gold-vs-gray check) for the first already-cleared/sweepable row. `bounty.py` (this project's own port of the near-identical `module/rewarded_task.py`) was the closer local analog to build from: same 3-area date-rotation pattern, same shared `SWEEP_CONFIRM_*`/stage-info-modal design already established.
Live-recon'd against nik-gpu (real account, AP 19/240 at the start) rather than guessing coordinates -- see the mapping.md row and `ba_auto/tasks/scrimmage.py`'s own docstring for the full findings; summarized here:
- Entry: Work-hub (`WORK_ICON`) -> new 学園交流会 card (`config.SCRIMMAGE_CARD`) -> Academy Select, 3 fixed area rows (トリニティ/ゲヘナ/ミレニアム, confirmed live identical layout for Trinity and Gehenna).
- Each area's stage list is a small FIXED 4-row grid (01 A / 02 B / 03 C / 04 D), no scrolling at all -- unlike bounty's scroll-to-extreme 10-stage list, this needed no scan logic at all: the hardest (D, row index 3) is just clicked directly every time, per explicit user direction.
- The 任務情報 stage-info modal turned out to be **pixel-identical** to bounty's own (same MIN/-/+/MAX stepper coordinates and colors, same cyan 掃討開始/gold 任務開始 two-stacked-button hazard bounty.py already documents at length) -- confirmed live via direct pixel comparison, not assumed.
- The AP guard the user asked for turned out to have a clean, direct implementation: the ticket-usage confirm dialog's own message text states its AP cost in plain digits ("学園交流会チケットをN、**APをM**使用して、掃討をN回行いますか", captured live). `_confirm_dialog_ap_cost` OCRs that figure (regex on the dialog text, same rect bounty's own `_confirm_dialog_is_sweep` OCR-reads) and cancels unless it reads exactly `0` -- a nonzero read AND an unrecognized/failed OCR read both fail closed (cancel), never assumed safe.
- Confirmed live via a real MAX-count (30) dry run, cancelled before confirming, that the AP delta preview stays `19→19` even at the full held-ticket count -- validating the pass covers bulk sweeps, not just count=1, before ever risking a real spend.
Implementation: `ba_auto/config.py` (new `SCRIMMAGE_*` section, kept fully decoupled from `BOUNTY_*` per this file's own established anti-coupling convention even though several stepper-button coordinates are pixel-identical -- see that section's own comment), `ba_auto/tasks/scrimmage.py` (new, closely modeled on `bounty.py`'s structure), `ba_daily.py` (registered as `scrimmage` in `TASKS`, added to the `daily` preset per explicit user choice -- not `q4h`, not `DEFAULT_ORDER`), `ba_auto/reference_notes/mapping.md` (new row). Per explicit user direction: `SCRIMMAGE_SWEEP_COUNT = "max"` (spend every held ticket per run), and no campaign-active guard (unlike story_sweep/story_sweep_hard -- runs every invocation regardless of the area's own キャンペーン中 badge state).
`bash -n`/`py_compile` passed locally and on nik-gpu's own venv; `python3 -c "import ba_daily"` succeeded on nik-gpu after deploying via the standard `rsync` push.
**Live-tested for real the same day, first invocation, with the user's explicit go-ahead** (`./ba_dailies.sh scrimmage` on nik-gpu): ran end-to-end cleanly -- rotation picked area 0 (トリニティ), targeted stage D directly, AP cost OCR'd and verified exactly 0 before the irreversible confirm click, MAX (30 tickets) swept, `_watch_sweep_result` correctly reported `"swept"` on the very first real attempt (validating `SCRIMMAGE_SWEEP_RESULT_BUTTON_REGION`, seeded from bounty's own hard-won post-fix region rather than the generic story_sweep/event_sweep one specifically to avoid repeating bounty's own first-live-test near-miss -- worked with no false positive this time), and returned cleanly to a confirmed true home screen. Verified via a real screenshot: credits rose 151,173,756 -> 152,253,756 (+1,080,000, consistent with the account's own active +200% reward campaign on a 30x sweep) while AP moved only 21->22 (one natural regen tick, not a sweep-caused drop) -- direct, real confirmation the AP==0 guard reflects an actually-zero real spend, not just a correctly-read preview number. Zero real tickets were spent during the recon phase itself (every dialog reached before the final live test was cancelled via Escape); scratchpad probes/screenshots from this session cleaned up afterward (locally and on nik-gpu) via `clean_scratchpad.sh`.
**Not yet exercised**: a day where the date-rotation picks ゲヘナ or ミレニアム specifically (only トリニティ was live-tested, since that's what today's rotation picked), an `inadequate_ticket` outcome (the account had 30 tickets, comfortably more than one MAX sweep needed), and a day where the AP guard actually fires and cancels (the monthly pass was active throughout this session, so `ap_cost != 0` was never really hit) -- all implemented per the same defensive shape confirmed working elsewhere in this project (bounty.py's own `_is_ticket_purchase_prompt`/fail-closed conventions), just not yet observed against real divergent game state.
## Prerequisites
### OCR
@ -1444,18 +1466,6 @@ Suggested first version (as originally scoped, and what shipped):
## Low priority backlog
### Scrimmage
Reference:
```
~/repo/baas-reference/module/scrimmage.py
```
Local target: `ba_auto/tasks/scrimmage.py`
Similar shape to Bounty/Commissions.
### Crafting
Reference: