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:
parent
52b10eda3d
commit
a690e90478
@ -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
404
ba_auto/tasks/scrimmage.py
Normal 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.")
|
||||
18
ba_daily.py
18
ba_daily.py
@ -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
34
plan.md
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user