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