"""Central configuration for ba-auto-daily, ported from the old ba_dailies.sh.""" import os DISPLAY = ":0" XAUTHORITY = "/run/user/1000/gdm/Xauthority" ENV = {**os.environ, "DISPLAY": DISPLAY, "XAUTHORITY": XAUTHORITY} WINDOW_NAME = "BlueArchive" # scrot occasionally writes a truncated/corrupt PNG (observed live as a # libpng "IDAT: invalid block type" decode error) even though the scrot # process itself exits 0 -- cv2.imread() returns None rather than raising on # a decode failure, so every color_at()/colors_at() call retries the whole # capture+decode cycle this many times before giving up, instead of crashing # the entire run on a single bad frame. See driver.py's _read_screenshot. SCREENSHOT_DECODE_RETRIES = 3 # Resolved relative to wherever this checkout physically lives (two levels # up from ba_auto/config.py) rather than a fixed path outside the repo -- # the runtime now runs directly out of the git checkout (no more separate # ~/ba_assets/~/ba_auto copies to drift out of sync with it, see CLAUDE.md's # "Deployment model"), so assets/scratchpad live wherever the repo does. PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ASSET_DIR = os.path.join(PROJECT_ROOT, "assets") # Runtime working files (probe/detection screenshots); never /tmp, per CLAUDE.md. SCRATCHPAD_DIR = os.path.join(PROJECT_ROOT, "scratchpad") os.makedirs(SCRATCHPAD_DIR, exist_ok=True) # Manual "I'm playing right now" pause switch. ba_daily.py's main() checks # this once, before dispatching anything (default flow, a preset, or a # single manually-typed task) -- both a scheduled cron fire and a manual # `./ba_dailies.sh ` are equally blocked while this file exists, so a # forgotten cron run can never fight the user for control of the game mid- # session. Presence = paused, absence = active; toggle via # `./ba_dailies.sh pause` / `./ba_dailies.sh resume`, or just touch/rm this # path directly (e.g. over ssh) if that's faster than the CLI. Lives inside # the repo checkout itself, per explicit user request, rather than # alongside the cron lock file in ~/ba_logs/ -- but it's gitignored (see # .gitignore's `/PAUSED`): this is host-specific mutable runtime state, the # same category as that lock file, not something that should ever be # committed or pushed between machines by the rsync in CLAUDE.md's # "Deployment model" (that command has no --delete, so it won't touch a # copy that exists only on nik-gpu either way). PAUSE_FLAG_PATH = os.path.join(PROJECT_ROOT, "PAUSED") # Refined from (1726, 60): that coordinate sat on the edge of the icon's # hitbox and intermittently missed during live testing. MAILBOX_ICON = (1732, 50) CLAIM_ALL = (1691, 1128) MISSION_ICON = (75, 350) # Background pixel inside the "一括受取" (claim all) button on the Mission # panel: bright yellow (r~250, r-b~180+) when something is claimable, flat # grey (r-b<20) when not. Calibrated live at 1920x1200. MISSION_CLAIM_PROBE = (1600, 1100) # Separate "デイリーミッションを8回クリア" (clear 8 daily missions) summary # bar's own 受取 (claim) button, ported from the reference's own # `collect_daily_task_power.py::implement` -- it checks TWO distinct # button regions, not one: the main 一括受取 area (MISSION_CLAIM_PROBE # above) is drained in a loop first, then this second, separate button # (reference's own "claim daily pyroxenes" region, positioned to the LEFT # of the main claim-all button in both the reference's layout and this # client's) is checked independently -- 一括受取 does NOT also claim this, # confirmed live (2026-07-16): a real account state showed this button # still claimable (a gem x20 reward) after 一括受取's own area had already # gone grey. Also unlike 一括受取, this button has no Enter keybind shown # on screen, so it needs an actual coordinate click rather than a keypress # (matching the reference's own raw click here too, `self.click(976, 670, # ...)`, rather than the img_reactions-driven Enter used elsewhere). # Same bright-yellow-vs-grey signature as MISSION_CLAIM_PROBE, confirmed # live at this exact point; pixel-scanned to sit clearly inside the button # and off the "受取" text glyphs (which read near-black, ~(75,33,22)). MISSION_DAILY_GEM_CLAIM_BUTTON = (1400, 1095) CAFE_ICON = (165, 1100) CAFE_ROOM_SWITCH = (190, 160) CAFE_INCOME = (1780, 1105) # Max sparkle-detection attempts per room (hits and misses both count -- # sparkles are on a per-student cooldown, so most checks legitimately find # nothing and the loop keeps polling rather than giving up after one miss). CAFE_MAX_CLICKS_PER_ROOM = 15 CAFE_SPARKLE_TEMPLATE = os.path.join(ASSET_DIR, "cafe_sparkle.png") # A pat that crosses an affection-rank threshold shows a full-screen "絆ラン # クアップ!" (Bond Rank Up!) cutscene with no cafe header visible at all. # Originally believed navigation.is_on_subscreen's single-pixel header probe # reliably told this apart from the real cafe screen (confirmed against # screenshots/cafe/student/01-02 at the time), but a real 2026-07-15 run hit # a character whose cutscene art happened to read bright at that exact # pixel, misreading the cutscene as already cleared -- see cafe.py's # _dismiss_rank_up_if_shown and navigation.is_header_bar_visible for the # fix (many header-row points must all read bright, not just one). Bounds # how many Enter presses _dismiss_rank_up_if_shown will try before giving up. CAFE_RANK_UP_DISMISS_RETRIES = 5 # Horizontal camera panning before farming, per explicit user direction # (2026-07-14): "due to my screen size, you need to move screen # horizontally left-right or you might miss a student... move screen most # right and most left then farm. No need for vertical move since it will # mess with the view." The reference's own module/cafe_reward.py handles # this differently (zoom_out() -- pinch/scroll to shrink the whole room # into view rather than panning to two extremes) but the user explicitly # asked for panning instead, which this project has no existing primitive # for -- driver.drag() was added specifically for this (distinct from # driver.scroll()'s wheel-based gesture, which is for list widgets, not a # room-view camera). # # Live-confirmed on nik-gpu: a drag from CAFE_PAN_RIGHT_X to CAFE_PAN_LEFT_X # (dragging the mouse leftward) pans the camera to reveal content further # RIGHT in the room (new furniture/students appeared on the right edge that # weren't visible before); the reverse drag (LEFT_X to RIGHT_X) reveals # content further LEFT (fully exposed the train-track corner and an # escalator/kiosk area that were partly cut off at the default view). HUD # elements (top status bar, CAFE_INCOME, the invite ticket buttons) stay # fixed on screen regardless of pan -- confirmed live across all 3 # calibration screenshots -- so no camera reset is needed before subsequent # fixed-coordinate clicks. One drag of this magnitude already reached the # true extreme in testing (a 2nd and 3rd drag in the same direction produced # an identical screenshot); CAFE_PAN_DRAG_REPEATS keeps a few anyway to # guarantee reaching the true extreme regardless of starting camera # position, matching this project's established scroll-to-extreme pattern # (event_sweep's/lesson's own list scrolling) -- overshooting is a # confirmed-harmless no-op, not a list-scroll gesture that could # misbehave. CAFE_PAN_DRAG_Y = 600 CAFE_PAN_RIGHT_X = 1500 CAFE_PAN_LEFT_X = 400 CAFE_PAN_DRAG_REPEATS = 3 CAFE_PAN_DRAG_DURATION = 0.8 # Cafe student invitation (招待券, module/cafe_reward.py's invite_girl/ # invite_by_affection). Per explicit user direction (2026-07-14): invite a # student into each room before farming it (a newly-invited student can be # patted the same run), preferring the HIGHEST-affection candidate, and # always skipping any candidate that would swap an already-seated student's # costume or move one in from the other room, rather than confirm either. # Live-calibrated against nik-gpu 2026-07-14, zero real tickets spent -- # every dialog reached during calibration was cancelled via Escape, and the # one row confirmed to reach a plain "通知" confirm dialog (ヒカリ) was also # cancelled rather than actually confirmed, since which student it would be # depended on the still-undecided invite criterion at the time. # Pink "招待券" button, bottom-right of the room view -- the "招待可能" label # above it (referenced in the user's own report) is not read directly; this # task instead clicks it and verifies the student list actually opened # (click-then-verify, matching this project's established convention), # which fails safely the same way whether the real cause is "no ticket # available right now" or "the click missed." CAFE_INVITE_TICKET_ICON = (1345, 1085) # MomoTalk student-list panel's own close button (top-right X). CAFE_INVITE_LIST_CLOSE_BUTTON = (1266, 204) # 並び替え (sort) controls, top of the list. Confirmed live: the list # defaults to sorting by 絆ランク (bond rank / affection) already, but this # task explicitly (re-)selects it every run rather than trusting whatever a # previous manual session left selected -- matching the reference's own # explicit change_order_type step, just via this client's own submenu # instead of the reference's paged menu. CAFE_INVITE_SORT_FIELD_DROPDOWN = (1088, 289) # Sort DIRECTION toggle -- confirmed live clicking this flips the whole # list between ascending/descending immediately (verified both directions: # descending showed 38,35,24,22,21; ascending showed 1,2,2,2,3 for the same # account). Rather than reading the icon's own arrow glyph, _ensure_invite_ # sort compares the top two rows' actual OCR'd affection values to decide # whether a toggle click is needed -- more robust than glyph-matching and # reuses the same OCR path already needed for picking a candidate. CAFE_INVITE_SORT_DIRECTION_TOGGLE = (1242, 289) # "絆ランク" option inside the 並び替え submenu (a 2x2 grid: 名前/学校 on # top, 絆ランク/お気に入り・日直 on bottom) opened by the dropdown above, # and that submenu's own OK button to confirm the selection. CAFE_INVITE_SORT_BOND_RANK_OPTION = (795, 538) CAFE_INVITE_SORT_OK_BUTTON = (957, 651) # First 5 visible rows of the list (no scrolling) -- matches the # reference's own invite_by_affection bound (its own lo=[226,309,378,456, # 536] is the same "try the first 5, give up" shape, just at this client's # different row spacing/resolution). Each row shows a portrait, name, # heart-shaped affection badge, and a 招待 (invite) button. CAFE_INVITE_ROW_Y = (420, 537, 653, 770, 887) CAFE_INVITE_BUTTON_X = 1155 CAFE_INVITE_HEART_X = 758 # Affection badge OCR half-size, offset from (CAFE_INVITE_HEART_X, row_y). # This badge is pixel-confirmed the same pink-heart-with-navy-digit style # lesson.py's own heart badges use (digit pixels sampled live: RG, e.g. (243,184, # 210)) -- detector.read_int_on_heart_badge is reused directly rather than # building a second OCR path for what's confirmed to be the same widget. CAFE_INVITE_HEART_OCR_HALF_SIZE = (40, 23) # The dialog raised by clicking a row's 招待 button. Live-confirmed 3 # distinct cases, all sharing the exact same "通知"-style dialog component # this project already uses everywhere else -- SWEEP_CONFIRM_BUTTON/ # SWEEP_CONFIRM_CANCEL_BUTTON (defined above under story_sweep) sit at the # pixel-identical position/color here too and are reused directly rather # than re-declared: # - Normal (title "通知", e.g. "ヒカリをカフェに招待します。"): safe, no # existing student is affected -- confirm via SWEEP_CONFIRM_BUTTON. # - "衣装替え" (costume change): the target is a different costume variant # of a student already seated in THIS room (live-confirmed: inviting # "ミカ" while "ミカ(水着)" was already in room 1 raised this, showing # both portraits with an arrow between them) -- confirming would swap the # current occupant's outfit rather than seat an additional student. Per # explicit user direction, always skipped. # - "隣のカフェの生徒を招待" (invite a student from the neighboring cafe): # the target is currently seated in the OTHER room (live-confirmed via a # student showing a "2号店" tag on her portrait in room 1's list) -- # confirming would move them out of it. Per explicit user direction, # always skipped. # Told apart by OCR'ing the title bar and checking for either warning's own # distinctive substring ("衣装" / "隣") rather than requiring an exact full # title match, for the same OCR-noise tolerance reasoning as event_sweep's # own "終了" substring check. CAFE_INVITE_DIALOG_TITLE_RECT = (550, 270, 1370, 340) # Home -> お仕事 (Work hub) -> 任務 (Task) card -> Normal/Hard story region browser. WORK_ICON = (1793, 1138) # Moved up from the original (1370, 450): that point sat close enough to the # 任務 card's bottom edge that a live Phase 10 run missed and landed on the # "総力戦" (Total War) card in the row below instead -- confirmed live via # screenshot, not just a hunch. (1250, 380) sits solidly mid-card, on the # "任務" title text itself, well clear of every edge. TASK_CARD = (1250, 380) # Normal/Hard toggle tab atop the region browser's stage-list panel. # story_sweep.py explicitly clicks+verifies this (mirroring # story_sweep_hard.py's own HARD_TAB/_open_hard_tab) rather than assuming # Normal is always the screen's default -- added 2026-07-21 once # story_sweep_hard.py started running earlier in the same q4h preset # sequence and could plausibly leave Hard selected. NORMAL_TAB_ACTIVE_PROBE # is sampled clear of the "Normal" glyph itself (dark navy background, # confirmed live 2026-07-21); note this is a DIFFERENT active-tab color than # Hard's own red (HARD_TAB_ACTIVE_RGB below) -- each tab has its own accent. NORMAL_TAB = (1200, 297) NORMAL_TAB_ACTIVE_PROBE = (1100, 297) NORMAL_TAB_ACTIVE_RGB = ((20, 40, 60), (100, 120, 140)) REGION_RIGHT_ARROW = (1862, 598) # Pixel-scanline-scanned (not visually estimated -- see plan.md Phase 8's # lesson) from scratchpad/stage_info.png: the "<" chevron's navy-blue pixel # centroid was (66, 597), mirroring REGION_RIGHT_ARROW. Used for the # OCR-driven to_region port (ba_auto/tasks/story_sweep.py) to step backward # when the current region is past the target. REGION_LEFT_ARROW = (66, 598) # Bounds the "read region, click delta, re-check" loop in # ba_auto/tasks/story_sweep.py's _go_to_region. A correct read normally # converges in one round; this just guards against a stuck OCR misread. REGION_NAV_MAX_ATTEMPTS = 8 # Region-number readout on the region browser's left panel (the "Area 30" # card's big digits, below the smaller "Area" label). Rect pixel-scanned from # scratchpad/stage_info.png: the "Area" label occupies roughly y 295-325, the # number itself y 330-372 -- this rect isolates just the digits. Replaces # _go_to_latest_region's "spam the arrow and hope" (see plan.md Phase 9's # retrospective) with task_utils.py::to_region's actual OCR-read-and-click- # the-exact-delta approach. REGION_NUMBER_OCR_RECT = (175, 325, 250, 380) # Stage list panel (right side of the region browser). Scrolling to either # extreme always shows exactly 4 full stage rows, since every region has at # least 5 stages -- scrolling past either end is a harmless no-op (verified # live), so a generous bounded click count is safe. Each stage row's "入場" # (enter) button sits at STAGE_ENTER_X across both scroll extremes; the two # row-position sets below were measured at each extreme. STAGE_LIST_SCROLL_POINT = (1400, 700) # 10 was the original (Phase 9) calibration, but live re-testing during # Phase 10 found it insufficient to reach the opposite extreme when the list # was already scrolled near the other end (it undershot, landing between the # two calibrated row-position sets and producing garbled OCR reads) -- 20 # reliably reached either extreme regardless of starting position. Scrolling # past either end remains a harmless no-op (verified live both phases). STAGE_LIST_SCROLL_CLICKS = 20 STAGE_ENTER_X = 1683 STAGE_ROWS_AT_TOP_Y = (424, 570, 718, 866) STAGE_ROWS_AT_BOTTOM_Y = (483, 630, 778, 926) # Stage-label OCR rect (e.g. "30-1", "30-A"), offset from a row's known # center y above. Pixel-scanned across all eight row positions (both # STAGE_ROWS_AT_TOP_Y and _BOTTOM_Y) in live captures -- x[1030,1150], # y[row_y-40, row_y-4] consistently isolates just the label text line above # the row's star-rating icons. The symmetric row_y+/-40 crop tried first # during calibration included the stars below and reliably broke OCR (empty # or garbled reads) even with a character whitelist -- see plan.md Phase 10. # Replaces _pick_random_stage_row's "scroll to an extreme, grab a random one # of the 4 rows" with a scoped-down port of the reference's # swipe_search_target_str: since this client's stage list only ever has 2 # relevant scroll positions (top/bottom extremes, both already known-good), # full swipe-and-retry generality isn't needed -- just OCR each of the 4 # visible rows at each extreme and match by label text. # # bottom_pad=4 (i.e. cropping up to row_y-4) turned out to still clip the # bottom of a "2" glyph's flat closing stroke just enough to make tesseract # read it as "9" -- confirmed live 2026-07-24 (region 29's real rotation # target landed on stage 2 for the first time since switching the rotation # region from 30, and every single OCR config tried against the *unmodified* # rect misread "29-2" as "99-9"/"90-9"/etc., even though the crop looks # completely unambiguous to the eye -- see scratchpad/probe_stage_ocr_*.py). # "1"/"3"/"4"/"5" never showed this because they don't have that bottom # stroke shape. Extending the crop 8px further down (bottom_pad=-4, i.e. # row_y+4) gave a clean, unanimous "29-2" across every threshold/psm/oem # combination tried, without breaking any of the other seven already-correct # row reads (validated across both scroll extremes) -- a geometry fix, not a # digit-guessing one, matching detector.py's own read_int_bordered precedent # for a different tesseract edge-clipping misread. STAGE_LABEL_OCR_X = (1030, 1150) STAGE_LABEL_OCR_Y_PAD = (40, -4) # 任務情報 (stage info) modal's sweep sub-panel. This modal is wide enough # that navigation.MODAL_DIM_PROBE (960, 200) lands on the modal's own white # card instead of the dimmed backdrop -- use a corner point that's outside # the card in either scroll/region state instead. STAGE_MODAL_PROBE = (1870, 600) # Regular numbered stages (30-1..30-5) render an extra "集中指揮"/"簡易攻略" # tab row and a manual "任務開始" panel below the sweep panel that the # bonus "-A" stage Phase 9 originally calibrated against does not have -- # discovered live during Phase 10 when Phase 9's coordinates (calibrated # only against 30-A) missed the MAX button on 30-3 by ~43px vertically. # These values are pixel-scanned against 30-3's tabbed layout and are what # config.STORY_SWEEP_TARGETS will hit in the common case (sweeping a # regular numbered stage, not the "-A" bonus stage). If a target's stage is # "A", these may be off by the same ~43px the old (untabbed) calibration # used -- not yet re-confirmed against an actual "-A" stage since this fix; # see plan.md Phase 10. SWEEP_MAX_BUTTON = (1620, 550) SWEEP_START_BUTTON = (1400, 710) # The "-" stepper button next to the sweep count: flat grey (240,240,239) # while count is still at its default of 1, vivid orange (255,111,0) once # MAX (or any +) has raised it. Used to verify the MAX click actually landed # instead of trusting a single click blindly, since this gates real AP spend. SWEEP_MINUS_BUTTON_PROBE = (1285, 555) # The modal's own "X" close icon (top-right corner of the white card). # Escape does NOT close this modal (confirmed live: two Escape presses left # it open with focus on the live "任務開始"/start-mission button) -- must # click this explicitly. Pinned via pixel-scanline scan of the glyph's # crossing point, not visual estimation. # # The whole card (not just the sweep sub-panel) is vertically centered on # its own content height rather than anchored at a fixed absolute position # -- confirmed live during Phase 10: the tabbed regular-stage layout (see # SWEEP_MAX_BUTTON above) is taller than the "-A" bonus-stage layout this # was originally calibrated against, and its X button sits ~46px higher # on screen (225 vs the old 271) as a result. This value is re-measured # against the taller, tabbed layout. STAGE_MODAL_CLOSE_BUTTON = (1691, 225) # "+" stepper button, for configured exact (non-"max") sweep counts. Pinned # via color-scan (bright cyan glyph centroid) against 30-3's tabbed layout # (see SWEEP_MAX_BUTTON above) -- unlike SWEEP_MAX_BUTTON/ # SWEEP_MINUS_BUTTON_PROBE this specific button has NOT been live-clicked # yet; confirm it before relying on a non-"max" configured count (see # plan.md Phase 10). SWEEP_PLUS_BUTTON = (1520, 550) # "MIN" stepper button -- forces a known baseline of 1 before applying "+" # raises for a configured exact (non-"max") count. Added 2026-07-21: the # stepper remembers its last-used value across opens (confirmed live via # bounty.py's own real overspend incident, see plan.md Phase 15 and # CLAUDE.md's stepper-default guidance), so blindly clicking "+" count-1 # times on top of an unknown starting value could under- or over-shoot the # intended count. Same row/layout as SWEEP_MAX_BUTTON/SWEEP_PLUS_BUTTON, # pixel-scanned directly (confirmed live, the leftmost button in the # MIN/-/count/+/MAX row). SWEEP_MIN_BUTTON = (1180, 555) # Clicking 掃討開始 (start sweep) always raises an "AP使用して、掃討を # 回行いますか?" usage-confirmation dialog before the sweep actually # runs -- discovered live during Phase 10; the previous design had no # handling for this dialog at all, which is what a crude early placeholder # probe was misreading as "inadequate_ap" on every sweep, successful or not. # # If AP is too low for even one sweep (confirmed live by deliberately # emptying the count via MAX/"+" at low AP), a dialog that looks the same # and sits at the *same* OK-button position appears instead, but titled # "AP購入" (real-currency AP purchase) with a gold/yellow OK button instead # of cyan. The two are told apart by that color, not by position. SWEEP_CONFIRM_BUTTON = (1150, 810) SWEEP_CONFIRM_CANCEL_BUTTON = (770, 810) SWEEP_CONFIRM_CYAN = ((90, 190, 230), (200, 240, 256)) SWEEP_CONFIRM_GOLD = ((200, 200, 50), (256, 256, 150)) # Region spanning every button this task clicks through after 掃討開始: the # AP-usage-confirm OK above, and the "掃討完了" (sweep complete) results # screen's "SKIP" (first, skips the reward-reveal animation) and final "OK" # (after full reward totals appear) buttons. SKIP and the final OK share # SWEEP_CONFIRM_CYAN's color but sit ~120px apart vertically, so this task # finds whichever one is showing by color within this region instead of # hardcoding each dialog's exact Y position. SWEEP_RESULT_BUTTON_REGION = (700, 700, 1300, 1050) # Below this current AP, story_sweep/story_sweep_hard/event_sweep skip # outright (checked via navigation.current_ap right after driver.focus_game, # while still on the home screen) rather than spend a full navigation cycle # on a sweep that likely can't even afford one battle's worth of AP -- per # explicit user direction 2026-07-24 (found while diagnosing a real run that # left AP maxed out because a *different* bug, an OCR misread, silently # skipped the day's target; this is a separate, deliberate efficiency guard, # not a fix for that bug). Applies unconditionally, including the # story_sweep_force/story_sweep_hard_force variants -- force only bypasses # the campaign-active *business* gate in those modules, not this basic # feasibility floor. SWEEP_MIN_AP = 20 # (region, stage, count) targets to sweep, mirroring the reference's # unfinished_normal_tasks shape (module/explore_tasks/sweep_task.py). `stage` # is 1-5, or the string "A" for the bonus stage that only exists when # region % 3 == 0. `count` is a positive int (uses the "+" stepper) or the # literal string "max" (uses the in-game MAX button). # # Empty by default -- the earlier placeholder here was (1, 1, "max"), which # a real run then dutifully swept region 1 stage 1 instead of the account's # actual last region, since story_sweep has no "find the latest region" # heuristic anymore (see plan.md Phase 9's retrospective for why that # heuristic was removed). Add entries here for any additional fixed targets # you want swept every run, on top of the daily rotation target below. STORY_SWEEP_TARGETS = [] # Daily-rotating target: sweeps a single region, cycling through its stages # one per day rather than grinding the same stage every run, per explicit # user direction. `ROTATION_STAGE_COUNT` is how many stages that region has # (1..N); which one runs today is `today's date -> N` via a plain date # ordinal modulo, not the calendar day-of-year, so the cycle doesn't skip or # repeat around a year boundary. Set STORY_SWEEP_ROTATION_REGION to None to # disable this and only sweep STORY_SWEEP_TARGETS. STORY_SWEEP_ROTATION_REGION = 29 STORY_SWEEP_ROTATION_STAGE_COUNT = 5 STORY_SWEEP_ROTATION_COUNT = "max" # Hard story AP sweep (module/explore_tasks/sweep_task.py's sweep_hard_task). # ba_auto/tasks/story_sweep_hard.py reuses story_sweep's region-nav constants # (REGION_LEFT_ARROW/RIGHT_ARROW, REGION_NUMBER_OCR_RECT, # REGION_NAV_MAX_ATTEMPTS, WORK_ICON, TASK_CARD, STAGE_ENTER_X) and its # stage-info modal constants (STAGE_MODAL_PROBE, SWEEP_MAX_BUTTON, # SWEEP_MINUS_BUTTON_PROBE, SWEEP_PLUS_BUTTON, SWEEP_START_BUTTON, # STAGE_MODAL_CLOSE_BUTTON, SWEEP_CONFIRM_BUTTON/_CANCEL_BUTTON/_CYAN/_GOLD, # SWEEP_RESULT_BUTTON_REGION) directly -- confirmed live 2026-07-20 these are # pixel-identical between Normal and Hard's shared modal component (same # underlying UI widget). Only what's genuinely different about Hard gets its # own constants below. # Normal/Hard toggle tab atop the region browser's stage-list panel (opened # via WORK_ICON -> TASK_CARD, same entry point as story_sweep.py). Live- # calibrated 2026-07-20: clicking this stays selected across region # navigation -- no need to re-click after every REGION_LEFT_ARROW/ # RIGHT_ARROW click, unlike the reference's own to_hard_event/to_normal_event # re-assertion after every region move (that reference behavior exists # because uiautomator2 has no persistent notion of "which tab is active" # across a fresh screenshot the way this project's own state checks do). HARD_TAB = (1600, 297) # Active-tab red background, sampled clear of the "Hard" glyph itself, to # verify the click actually landed (matching this project's established # click-then-verify pattern) rather than trusting a single blind click. HARD_TAB_ACTIVE_PROBE = (1750, 297) HARD_TAB_ACTIVE_RGB = ((150, 0, 0), (230, 110, 110)) # Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A" # bonus stage, confirmed against the reference's own # explore_hard_task_region_range/available_missions=range(1,4)) and never # needs scrolling -- unlike Normal's up-to-6-row scrolling list, so no # OCR-based row search is needed, just a direct index by mission number. # Row-center y values pixel-scanned live 2026-07-20 (cyan-button-color scan # against scratchpad/hard_sweep_05_hard_tab.png, Area 30). Reuses # STAGE_ENTER_X (defined above under story_sweep) for the row's own 入場 # button x -- confirmed identical. HARD_STAGE_ROWS_Y = (435, 605, 775) # The row's own 入場 (enter) button reuses SWEEP_CONFIRM_CYAN's color range # (defined above under story_sweep) to tell an unlocked row (clickable, # button rendered in the same cyan as every other clickable button in this # project) from a locked one (greyed out) -- ports the reference's own # to_mission_info locked-button check, live-calibrated color instead of the # reference's own fixed rgb_in_range values (different screen layout). HARD_STAGE_ENTER_PROBE_HALF_SIZE = (25, 15) # Real-money hazard, confirmed live 2026-07-20: a Hard stage that has already # used all 3 of today's auto-sweep clears still shows a clickable (cyan) # 入場 button -- HARD_STAGE_ENTER_PROBE_HALF_SIZE's unlocked-vs-locked check # alone does NOT catch this. Opening its stage-info modal shows the sweep # count field itself already defaulted to "0" (a healthy stage defaults to # "1"), and clicking 掃討開始 with count=0 raises a completely different # "アラート" dialog ("Hard -の挑戦回数が不足しています。挑戦回数 # を回復させますか?", "あと1回回復可能", 消費する青輝石: 40 -- i.e. spend 40 # Pyroxene/blue gems to refill the daily clear count) with a GOLD OK button # that sits close enough to SWEEP_CONFIRM_BUTTON's position that a careless # reuse of that single-point probe could misread it, but is confirmed # live to NOT actually match either SWEEP_CONFIRM_CYAN or _GOLD at that # exact pixel (this dialog's own button sits a few px off), so a naive # _is_sweep_usage_confirm/_is_ap_purchase_prompt check alone would silently # fall through to "sweep-usage confirmation not detected, aborting" -- safe, # but relying on a coincidence rather than an actual check. Two independent # guards, matching this project's established defense-in-depth pattern for # gold-button hazards (see bounty.py, and story_sweep_hard.py's own # _confirm_dialog_is_sweep): (1) HARD_COUNT_FIELD_RECT is checked BEFORE ever # clicking MAX/掃討開始, so a stage already at 0 remaining is skipped without # reaching this dialog at all; (2) HARD_CHALLENGE_ALERT_TEXT_RECT OCR-detects # the dialog by its own text as a fallback, declining via Escape (its own # labeled ESC/キャンセル action) rather than any positional click, in case # the count somehow changes between the pre-check and the click (e.g. a # second concurrent session). HARD_COUNT_FIELD_RECT = (1338, 535, 1474, 575) HARD_CHALLENGE_ALERT_TEXT_RECT = (600, 385, 1330, 470) # Reward campaign banner, rendered directly on the region-info card (left # panel) whenever a reward campaign is active for whichever tab (Normal or # Hard) is currently selected -- confirmed live both times: "任務Hardで獲得 # できる報酬量が2倍(+100%)になっています。" (2026-07-20, Area 30/29) and # "任務Normalで獲得できる報酬量が2倍(+100%)になっています。" (2026-07-21, # Area 30), pixel-identical rect/color in both cases, only the tab name in # the text differs. Reads as account-wide, not per-region (same text on # multiple regions each time). No reference equivalent exists at all # (baas-reference has no campaign/drop-rate concept anywhere) -- per explicit # user direction, both story_sweep.py and story_sweep_hard.py refuse to spend # AP unless this banner is showing for their own tab, unless explicitly # overridden (force= parameter / the story_sweep_force / story_sweep_hard_force # CLI commands). The home screen's own smaller "キャンペーン中" badge (top-right # notice stack) was considered but rejected as the actual gate signal: it can # point at any of several concurrent campaigns (e.g. a totally unrelated # "大決戦開催中" banner sits right above it), not necessarily story-task # rewards specifically, while this in-panel banner explicitly names the tab. # Detected via a flat color-presence scan (not OCR) since the pink chip is # clean and high-contrast and the banner is either fully rendered or entirely # absent (never a different overlapping color) -- matching this project's # own established convention for unambiguous binary state signals (e.g. # gem_shop.py's claimed/unclaimed probe) rather than OCR'ing the banner text. # Named generically (not HARD_-prefixed) since both story_sweep.py and # story_sweep_hard.py share it -- originally added for Hard only, renamed # 2026-07-21 once story_sweep.py's own campaign check reused it unchanged. TASK_CAMPAIGN_BADGE_RECT = (164, 426, 344, 464) TASK_CAMPAIGN_BADGE_RGB = ((230, 100, 160), (256, 210, 256)) # (region, stage) priority-ordered targets to sweep, in the exact order given # by the user (highest farming priority first, not region-ascending) -- # unlike STORY_SWEEP_TARGETS these are plain (region, stage) pairs with no # per-target count: every target always uses MAX, capped at 3x by the game # itself (see the modal's own "残り回数:N/3" counter, discovered live), so # there's nothing to configure per-target beyond which stage. Stage is always # 1-3 (Hard has no "-A" bonus stage, see HARD_STAGE_ROWS_Y above). HARD_STORY_SWEEP_TARGETS = [ (18, 3), (30, 3), (27, 3), (28, 3), (17, 3), (13, 3), (23, 2), (16, 1), (20, 3), (17, 2), (15, 2), (14, 3), (10, 3), (14, 2), (7, 1), (5, 3), (4, 1), ] # Event sweep (module/sweep_activity.py -> module/activities/.py # -> module/activities/activity_utils.py's activity_sweep). Ported per # explicit user direction 2026-07-10: rather than the reference's config- # string sweep-list parsing (preprocess_activity_region/ # preprocess_activity_sweep_times, e.g. "9,10,11" stages x "0.5,3,1/3" # counts), this sweeps exactly one stage per run, chosen from a fixed # sub-range of the current event's stage list via the same date-ordinal- # modulo rotation as STORY_SWEEP_ROTATION_* (see story_sweep.py's # _rotation_target docstring for why that's a plain date ordinal modulo, not # day-of-year). # # Live-calibrated against nik-gpu on 2026-07-10 against the currently-running # "鉄道爆走事件" event (12 stages, all already 3-starred on this account) -- # see scratchpad/event_probe_* for the captured screenshots this was # pixel-scanned/OCR-tested against. Zero real AP was spent during calibration # (every confirm dialog reached was cancelled via Escape, verified by the AP # counter being unchanged before/after). # Home screen's top-right event badge/thumbnail slot. Chosen over the # bottom-left banner slot, which cycles between several unrelated banners # (gacha pickups, other campaigns) and was confirmed live to not reliably # reach the current story event either. This slot is ALSO a rotating # carousel, though -- confirmed live post-launch: it cycles between the # current event's own countdown (e.g. "終了まであと11日") and OTHER notices, # including an already-finished event's remaining reward-claim-period # reminder, so a single click can land on a stale event's page instead of # the current one. event_sweep.py's own wrong-page detection + retry (via # navigation.return_to_home) exists specifically to recover from this, since # no fixed click here is guaranteed to hit the right content on the first # try. Back-button navigation now goes through navigation.return_to_home's # shared BACK_BUTTON constant instead of a dedicated config entry here. # # EVENT_BADGE_DOT_X/_Y (the carousel's own pagination dots) removed # 2026-07-13 per explicit user direction/correction: the dots don't # reliably do anything ("it will click on the button under the event. The # button does nothing"), and the earlier "wait long enough for the slow # auto-rotate timer to land on the correct event" theory had the mechanism # backwards -- the current/ongoing event is the carousel's DEFAULT item # right when the home screen is reached, and it rotates away again within # a short window afterward. event_sweep.py's run() now clicks the badge # immediately after navigation.return_to_home confirms home, with no # artificial wait in between, instead of trying to force a specific # carousel page. EVENT_BADGE_ICON = (1787, 300) # A finished event's Quest tab shows plain "イベント期間が終了しました。" # (event period has ended) text instead of any stage-row cards. Used as an # early-exit, authoritative-when-positive check inside _find_stage_row's # scan loop -- a positive match ends the wait immediately as a confirmed # wrong page; a negative match does NOT prove the page is right (a # different finished event's layout might differ), so patient rescanning # still continues regardless either way up to the existing budget. If that # budget is ever fully exhausted with no rows and no confirmed # finished-page text, _find_stage_row saves a debug screenshot to # scratchpad/ (event_sweep_no_rows_debug.png) for further diagnosis. # # Re-calibrated 2026-07-29 (user report + user-supplied screenshot, # scratchpad/event_event.png, against "シャーレの総決算 with 連邦生徒会", the # event that succeeded "鉄道爆走事件"/"嵐過天晴"): the previous rect, # (1030, 600, 1810, 750), was live-confirmed against an EARLIER finished # event back on 2026-07-10, but re-tested live against this real capture # read pure garbage with no "終了" match at all -- not just clipped text, # almost entirely background office-art below the real text line, which # actually sits noticeably higher (y~585-635, not 600-750). Whether the # panel's own text position genuinely shifted between events or the # original calibration was never quite right, this rect is now confirmed # directly against a real live capture of the CURRENT finished-event page # (scratchpad/probe_event_finished_text.py, since deleted): the old rect # produced unusable noise ("glに月7」ししだに。..."), the new one reads # clean text containing "終了" ("4時回終了しました。"). If this drifts again # on some future finished event, re-run that same probe pattern against a # fresh capture rather than assuming the rect is still centered correctly. EVENT_FINISHED_TEXT_RECT = (1080, 585, 1760, 635) # Top-right tab bar inside the event screen (reference's activity_menu # story/mission/challenge tabs -- rendered as English labels "Story / Quest / # Challenge" even on this JP client). "Quest" is this client's rendering of # the reference's "mission" tab, the one that hosts the sweep-able stage # list. Clicked explicitly every run rather than trusting the screen's # default landing tab (it happened to default here during calibration, but # nothing guarantees that for a future event). EVENT_QUEST_TAB = (1416, 165) # Stage list, scrolled to its bottom extreme. Confirmed live this always # reveals the event's LAST 5 stages at these exact row positions regardless # of starting scroll state -- unlike story_sweep's region stage list, row # height here stayed constant across both 1-line and 2-line wrapped titles # during calibration, so no separate top/bottom row-position sets were # needed for this task's stages-9-12 target range. EVENT_STAGE_LIST_SCROLL_POINT = (1400, 700) EVENT_STAGE_LIST_SCROLL_CLICKS = 25 EVENT_STAGE_ROW_Y = (366, 538, 710, 883, 1055) EVENT_STAGE_ENTER_X = 1698 # Stage-number OCR crop (e.g. "09", "12"), offset from a row's known center # y -- rect = (x1, row_y - top_pad, x2, row_y + bottom_pad). Pixel-scanned # live across all 5 bottom-extreme row positions; tight enough to exclude # the 3-star rating row rendered just below each number. EVENT_STAGE_NUMBER_OCR_X = (1055, 1145) EVENT_STAGE_NUMBER_OCR_Y_PAD = (30, 5) # 任務情報 (stage info) modal. Corner probe is pixel-identical to # story_sweep's STAGE_MODAL_PROBE (same dark-backdrop dialog component) but # kept as its own constant to avoid coupling the two tasks' config together. EVENT_STAGE_MODAL_PROBE = (1870, 600) EVENT_STAGE_MODAL_DIM_MAX_CHANNEL = 150 # Sweep sub-panel. Unlike story_sweep, this modal has ONE fixed layout -- # confirmed identical across both stages tested live (09 and 12), no # tabbed-vs-plain variant to account for. EVENT_SWEEP_MAX_BUTTON = (1631, 511) EVENT_SWEEP_PLUS_BUTTON = (1517, 511) # The "-" stepper button's fill: flat grey while count is still at its # default of 1, a distinctly saturated coral/orange once MAX (or any "+") # has raised it -- confirmed live (171,172,171) vs (251,173,152). Told apart # by color *spread* (max-min channel) rather than story_sweep's simpler # r>200-and-g<180 rule, since this button's raised color isn't as vividly # orange. EVENT_SWEEP_MINUS_BUTTON_PROBE = (1281, 511) EVENT_SWEEP_START_BUTTON = (1400, 668) # Modal close. Confirmed live this modal DOES close on Escape (unlike # story_sweep's, which doesn't -- see STAGE_MODAL_CLOSE_BUTTON above); the # X button position is kept as a fallback if Escape ever doesn't clear it. EVENT_STAGE_MODAL_CLOSE_BUTTON = (1704, 271) # The AP-usage confirmation dialog raised by 掃討開始, and the post-sweep # "掃討完了" SKIP/OK result screen, reuse SWEEP_CONFIRM_BUTTON/ # SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD directly # (defined above under story_sweep) -- confirmed live pixel-identical # position and color, since it's the same shared dialog component both # tasks reach after their own 掃討開始 click. # # NOT shared: SWEEP_RESULT_BUTTON_REGION. Confirmed live (2026-07-11, # scratchpad/probe_result_button_fp.py) that region's x-range (700-1300) # reaches into this event's own Quest-list character-art panel on the # left, which false-positive-matched SWEEP_CONFIRM_CYAN on the plain list # with NO sweep-result dialog showing at all -- this is why # _watch_sweep_result kept "finding" a result button and never reached its # "modal closed, no result button" end condition after a real sweep # finished, even though the sweep itself succeeded (AP spent, credits # gained, confirmed by screenshot). Narrower x-range here (1000-1300) # still comfortably covers the real buttons (SWEEP_CONFIRM_BUTTON's own # x=1150 sits well inside it) while excluding the character-art panel, # confirmed against the same saved false-positive screenshot returning # no match afterward. # # A SECOND contamination source found live (2026-07-13, reported by the # user: "the sweep went well, but I think it overclick and closed the # result page"): this region's y1=700 overlaps EVENT_SWEEP_START_BUTTON's # (1400,668) own real cyan-pixel footprint (y 622-715, x 1152-1655 -- # measured directly during bounty.py's own identical-bug investigation, # since that modal is confirmed pixel-identical to this one, sharing this # exact button position). Once the real "掃討完了" result dialog is # dismissed and the flow lands back on the bare stage-info modal (matching # this function's own second `ends` condition), 掃討開始 becomes visible # and cyan again -- _find_result_button re-matched its corner, and # click_result_button clicked it, re-opening a fresh AP-usage-confirm # dialog that this loop then had no way to recognize as anything other # than "still a result button showing," matching the user's own "overclick # closed the result page" diagnosis exactly. y1 shifted to 730 (15px clear # of the button's measured 715 bottom edge) to exclude it -- kept wide # enough (through y2=1050) to still cover both a possible SKIP button and # the final OK button of a bulk/MAX sweep's reveal sequence (~120px apart # per story_sweep's own established pattern), since this event's own SKIP # button was never individually pixel-measured. Not yet re-confirmed live # with a fresh real sweep -- see EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS' # comment for the second half of this fix and its own live-test status. EVENT_SWEEP_RESULT_BUTTON_REGION = (1000, 730, 1300, 1050) # A THIRD contamination source, same class bounty.py's own investigation # found in its pixel-identical modal: the stage-info modal's own "獲得期待 # 報酬" reward-icon-preview artwork has a handful of pixels that # incidentally fall inside SWEEP_CONFIRM_CYAN's broad range even with no # dialog open. detector.find_color_centroid's min_pixels parameter filters # this out. Value carried over from BOUNTY_RESULT_BUTTON_MIN_PIXELS by # analogy (same shared UI component) rather than freshly measured against # this event's own region -- its search area here is somewhat larger, so # this may need tuning once a fresh live sweep confirms whether it's # actually well-calibrated for this specific region. EVENT_SWEEP_RESULT_BUTTON_MIN_PIXELS = 3000 # Rotation target: which of stages 9-12 to sweep today, per explicit user # request (2026-07-10) -- "the current event has up to 12 stages, randomly # choose stage 9-12, same mod%4 date method as story sweep." Same # date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*, just over this # sub-range instead of 1..N. Set EVENT_SWEEP_ROTATION_STAGE_MIN to None to # disable. # # NOT yet live-confirmed: what the stage-info modal looks like for a stage # that has never been cleared to SSS -- the reference's own activity_sweep # gates the sweep panel on check_sweep_availability()=="sss" and falls back # to a manual fight otherwise. Every stage 9-12 on this account was already # 3-starred during calibration, so that gate was never actually exercised. # event_sweep.py handles this the same defensive way story_sweep handles an # unavailable region/stage: if the MAX-button click can't be verified to # have raised the sweep count, it aborts that target without spending AP # rather than guessing. EVENT_SWEEP_ROTATION_STAGE_MIN = 9 EVENT_SWEEP_ROTATION_STAGE_MAX = 12 EVENT_SWEEP_ROTATION_COUNT = "max" # Common Shop / Tactical Challenge Shop. Both tabs share the same underlying # checkbox-grid-then-bulk-buy UI (the live equivalent of the reference's # module/shop/shop_utils.py get_item_position/ensure_choose/buy pattern); # see ba_auto/tasks/shop_utils.py for the shared control flow. SHOP_ICON = (1155, 1085) # bottom nav "ショップ" icon on the home screen # SHOP_BACK_BUTTON removed 2026-07-12 (redundant with navigation.BACK_BUTTON, # the single source of truth for this shared coordinate -- both shop tasks' # own back-clicks now go through navigation.return_to_home instead of a # bare click, matching EVENT_BACK_BUTTON's earlier removal for the same # reason). SHOP_TAB_COMMON = (160, 208) # 通常アイテム tab (credit-point items) # 戦術対抗戦 tab (tactical-coin items). The reference reaches this via # goto_shop_by_name's OCR swipe-search over the shop-type tab list # (module/shop/shop_utils.py) because that list can require scrolling on # some accounts/versions. Confirmed live here: this account's tab list is # only 7 entries and all fit on screen with no scroll needed, so a fixed # click is faithful (there is nothing to search for), not a shortcut around # the OCR the reference would otherwise need. SHOP_TAB_TACTICAL = (160, 915) # Item grid checkbox top-left-ish click point per (row, col), pixel-scanned # live against the shop's real catalog (see plan.md's shop phase). Confirmed # identical across both shop tabs -- it's the same shared UI component. SHOP_ITEM_COL_X = [972, 1197, 1423, 1649] SHOP_ITEM_ROW_Y = [297, 674] # The checkbox glyph renders this vivid yellow-green only once checked # (plain white/grey otherwise) -- pixel-sampled from a live checked vs. # unchecked capture of the same card. SHOP_CHECKED_RGB = ((60, 130, 80), (220, 245, 115)) # Each item's price-digit crop, as an (x1, y1, x2, y2) offset added to that # item's own (col_x, row_y). Pixel-scanned and OCR-tested live against all 8 # configured targets' actual prices (12,500 up to 500,000) -- wide enough # for the largest configured price without bleeding into the neighboring # column's card. SHOP_PRICE_OCR_OFFSET = (48, 166, 145, 195) # A real live run (script_error.md, 2026-07-31) misread '中級レポート's price # as 1250060 instead of 125000, and this recurred identically every day for # several days afterward -- initially (mis)diagnosed as a one-off tesseract # flake and "fixed" with the retry loop below, but the real cause (found by # live screenshot inspection, 2026-08-01) is that this shop sinks any # sold-out item to the bottom of the grid and shifts everything after it # up by one slot -- '初級レポート' (originally col 0) sold out and sank, # sliding '中級レポート' from col 1 into col 0, so the fixed-position config # was reading whatever backfilled col 1 instead. Retries alone can't fix a # deterministically-wrong position, only a genuinely flaky single read -- # kept for that narrower case (and still used by the position-based # select_targets, i.e. the Tactical Shop path below), but Common Shop's own # targets are now matched by OCR'd item name instead (see # shop_utils.select_targets_by_name), immune to this reordering. SHOP_PRICE_OCR_RETRIES = 3 # Item-name-text crop, as an (x1, y1, x2, y2) offset added to a cell's own # (col_x, row_y) -- used by shop_utils.select_targets_by_name to identify # an item by its name rather than assuming a fixed grid position (see # SHOP_PRICE_OCR_RETRIES's comment above for why position alone isn't # reliable). Pixel-scanned and OCR-tested live against every current # Common Shop target name (single-line names only -- two-line-wrapping # names like the bundle/material items sit differently and aren't covered, # but none of the configured targets below wrap). SHOP_NAME_OCR_OFFSET = (-75, -105, 160, -45) # A point inside each cell's own buy-button bar. First calibrated against # two downloaded screenshots as a plain brightness check (purchasable # bright ~(235,241,241) vs. sold-out/greyed ~172-174 flat) -- but a live # same-session A/B (2026-08-01, after the button-color check found nothing # purchasable on its first real run) showed the button's fill genuinely # shimmers/animates: the SAME real purchasable button read anywhere from a # dark navy (45,70,99) to a bright cyan (126,222,253) across different # moments, sometimes dimmer than the sold-out reference. What stays # constant regardless of animation phase is the blue TINT, not brightness: # purchasable consistently reads B-R of +120 to +127 (any phase sampled), # sold-out/greyed consistently reads B-R within +/-2 (flat R==G==B, no # tint at all, confirmed across all 8 grid positions in two separate # screenshots). SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT is the (B-R) # threshold, set well clear of both clusters. SHOP_BUTTON_PROBE_OFFSET = (90, 210) SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT = 40 # One driver.scroll(..., clicks=N) call at this many clicks reliably # advances the grid by exactly one row (confirmed live: the item at # row_y[1] before the scroll reappears at row_y[0] after it, same name and # price) -- so select_targets_by_name can scan two full rows per # screenshot and scroll by exactly one row's worth between reads, without # skipping or double-reading a row. SHOP_SCROLL_STEP_CLICKS = 3 SHOP_SCROLL_POINT = (1310, 485) # Hard cap on scroll steps for select_targets_by_name, independent of its # own "screenshot unchanged after scrolling" bottom-of-list detection -- # the live catalog was ~6 rows deep (24 slots) when this was built, so this # leaves headroom without scrolling forever if that detection ever misses. SHOP_NAME_SCAN_MAX_STEPS = 12 SHOP_BUY_BUTTON = (1751, 1112) SHOP_CANCEL_BUTTON = (1525, 1112) # A corner point that both the purchase-confirm dialog and the post-purchase # "報酬獲得!" (reward acquired) banner dim away from pure white as they # cover the screen -- confirmed live across both, and confirmed to stay pure # white with no dialog open (across tab switches and scrolling). Cheaper and # more robust than tracking each dialog's own layout individually. SHOP_OVERLAY_PROBE = (100, 600) SHOP_OVERLAY_IDLE_MIN_CHANNEL = 200 # Bounds shop_utils.confirm_purchase's "press Enter until idle" loop. # Confirmed live: exactly 2 presses clears both the confirm dialog and the # reward banner in one purchase; this leaves headroom for any additional # one-time popup (e.g. a first-time notice) without spinning forever if the # game is ever in a state this project doesn't recognize. SHOP_PURCHASE_MAX_ENTER_PRESSES = 6 # Top status bar credit-point balance -- present on every screen, not # shop-specific. Rect excludes the currency icon on the left, which OCR # otherwise misreads as a spurious leading digit (confirmed live). CREDIT_BALANCE_OCR_RECT = (1040, 15, 1290, 55) # The Tactical Challenge Shop's own coin-balance readout, shown inline above # the item grid on that tab specifically (not in the top status bar). TACTICAL_COIN_OCR_RECT = (1090, 100, 1290, 150) # (item name, expected credit-point price) -- matched by OCR'd name # wherever it currently sits in the (scrollable) grid, not by fixed # position; see SHOP_PRICE_OCR_OFFSET's comment above for why position # alone broke. Buy-list confirmed with the user, 2026-08-01, after the # catalog turned out to have grown well beyond these original 8 items # (bundle packs, crafting-material items) -- those new items were # deliberately left out of auto-buy for now, user's own call. Each of the # 4 "強化珠" tiers also now has a second, escalated-price repeat-purchase # slot once its base slot sells out for the cycle (confirmed live: exactly # 2x the price below) -- select_targets_by_name only ever matches the # exact price below, so it deliberately never chases that escalated tier, # also the user's own call (skip skip, don't overspend chasing it). COMMON_SHOP_TARGETS = [ ("初級レポート", 12500), ("中級レポート", 125000), ("上級レポート", 300000), ("最上級レポート", 500000), ("初級強化珠", 10000), ("中級強化珠", 40000), ("上級強化珠", 96000), ("最上級強化珠", 128000), ] # (row, col, item name, expected tactical-coin price). Buy-list confirmed # with the user; both visible without scrolling. TACTICAL_SHOP_TARGETS = [ (0, 0, "初級栄養ドリンク(AP30)", 15), (0, 1, "中級栄養ドリンク(AP60)", 30), ] # Lesson/Schedule (module/lesson.py). This client renders the reference's # paged single-region view as a scrollable "Location Select" list of 12 # named regions instead -- these names are the reference's own # lesson_region_name.JP list (core/config/default_config.py), embedded there # directly rather than fetched externally, so hardcoding it locally isn't an # external-data problem the way the shop price table was. They're used only # for logging here, not for OCR identification -- unlike the reference's # paged arrows, this list's scroll position is deterministic (see # REGION_ROW_Y below), so there's nothing to OCR-locate. LESSON_ICON = (314, 1100) # bottom nav "スケジュール" icon on the home screen # LESSON_BACK_BUTTON removed 2026-07-12 (redundant with navigation.BACK_ # BUTTON, the single source of truth for this shared coordinate -- every # back-click in lesson.py now goes through navigation.click_back/ # return_to_home instead of a bare click, matching EVENT_BACK_BUTTON's # earlier removal for the same reason). # Clicking LESSON_ICON does not always land on the Location Select list -- # confirmed live: the game remembers the last-viewed region and reopens # directly to its per-region isometric map instead (e.g. after a previous run # was interrupted mid-region). lesson.py's _ensure_location_select_list # detects and recovers from this via LESSON_ALL_SCHEDULES_BUTTON below # already being visible before any row has been clicked. LESSON_REGION_NAMES = [ "シャーレオフィス", "シャーレ居住区", "ゲヘナ学園・中央区", "アビドス高等学校", "ミレニアム・スタディーエリア", "トリニティ・スクエア", "レッドウインター連邦学園", "百鬼夜行中心部", "D.U.シラトリ区", "山海経中央特区", "春葉原", "ワイルドハント総合芸術地区", ] # The region list only ever settles at two scroll positions -- scrolled fully # to top (regions 0-5 visible) or fully to bottom (regions 6-11) -- confirmed # live: 15 scroll-down clicks always lands on the same bottom state, it # doesn't keep scrolling past it. Each row's card is clickable at this # center-ish Y regardless of which of the two scroll states is showing. LESSON_REGION_ROW_Y = [265, 420, 585, 745, 900, 1060] LESSON_REGION_LIST_SCROLL_POINT = (1400, 700) LESSON_REGION_LIST_SCROLL_CLICKS = 15 LESSON_REGION_ROW_X = 1400 # Per-region isometric map screen -- opens the "全てのスケジュール" grid # modal (this client's rendering of the reference's per-region 3x3 # get_lesson_each_region_status/get_lesson_relationship_counts grid). LESSON_ALL_SCHEDULES_BUTTON = (1770, 1118) LESSON_GRID_MODAL_CLOSE_BUTTON = (1710, 211) # Grid modal cell layout: up to 3 columns x 3 rows of location cards, each # showing up to 3 student portraits with a heart-shaped affection-count # badge at bottom-right. Pixel-scanned live against two different regions' # modals (Gehenna Central: 8 cells, Schale Office: 7 cells) -- consistent # across both. A region with more than 9 currently-unlocked locations would # need scrolling inside this modal, which isn't implemented (not yet seen # live on this account; see plan.md). LESSON_GRID_COL_X = [270, 786, 1302] # portrait-slot-0 center per column LESSON_GRID_ROW_HEADER_Y = [380, 608, 836] # click target to open a cell's info panel LESSON_GRID_ROW_PORTRAIT_Y = [486, 713, 940] LESSON_GRID_PORTRAIT_STEP_X = 109 # slot 1/2 center = slot 0 center + N * this # Badge center sits below-right of each portrait-slot's own center point. LESSON_GRID_BADGE_OFFSET = (38, 24) LESSON_GRID_BADGE_OCR_HALF_SIZE = (26, 20) # crop half-width/half-height around badge center # A green checkmark can appear at top-right of a portrait ALONGSIDE its # unchanged heart badge number, not instead of it (confirmed live -- an # earlier assumption that "done" always blanks the number was wrong) -- # this is the only reliable "already done today" signal, so it's checked # separately rather than inferred from the badge OCR. LESSON_GRID_CHECKMARK_OFFSET = (41, -31) LESSON_GRID_CHECKMARK_RGB = ((130, 190, 60), (220, 255, 160)) LESSON_GRID_CHECKMARK_HALF_SIZE = (14, 13) # A masked, digit-only OCR read of the heart badge can still occasionally # fuse a stray leading digit from portrait art bleeding into the crop's left # edge (confirmed live: "13" -> "413", "18" -> "418", reproducible across # every psm mode -- see detector.read_int_on_heart_badge). Real affection # values never reach this range in practice, so treat anything this large # as contamination and discard it rather than trust it. LESSON_GRID_BADGE_MAX_PLAUSIBLE = 99 # "保有チケット N/M" readout, top-left of the region-list/map screens. # The original rect (295, 133, 385, 172) clipped in a stray fragment of the # "チケット" label's own trailing katakana glyph a few pixels left of the # first digit -- confirmed live: it read back as "/7" (whole leading digit # dropped) instead of "7/7", reproducible across a fresh screenshot every # time, not a one-off render glitch. Tightening the left edge past that # fragment (and the right edge in to match) fixed it; re-confirmed stable # across 6 consecutive fresh reads. See Handoff.md for the live debugging # session that found this. LESSON_TICKET_OCR_RECT = (315, 135, 375, 170) # Per-cell info panel ("スケジュール情報"), opened by clicking a grid cell. # Its Start button and the post-schedule report's OK button (below) are the # same bright-cyan gradient button at nearly the same position -- confirmed # by direct pixel sample, sharing one color range. LESSON_ACTION_BUTTON_RGB = ((90, 195, 235), (150, 240, 255)) LESSON_INFO_START_BUTTON = (960, 890) LESSON_INFO_CLOSE_BUTTON = (1435, 240) # After clicking Start, a variable sequence of intermediate screens can # appear before settling back on the grid modal -- a bond-rank-up full- # screen cutscene (confirmed live, see screenshots/lesson/ -- its art is # character-dependent, so no fixed color/position reliably identifies it) # and/or a "スケジュールレポート" results modal. A transient "run the # schedule twice" campaign multiplier was also observed live, doubling how # many of these screens appear in a row -- rather than special-case any of # this, `lesson.py` presses Enter in a bounded loop and re-checks two fixed # markers each round: # # - the grid modal's own title underline (this exact yellow-gold, confirmed # live) is visible ONLY when the grid modal is frontmost and idle -- both # the report modal and the cutscene cover it, confirmed live against all # three states. LESSON_GRID_IDLE_PROBE_RECT = (790, 244, 1130, 252) LESSON_GRID_IDLE_RGB = ((235, 220, 70), (255, 250, 130)) # - the report modal's OK button -- same color as LESSON_ACTION_BUTTON_RGB, # its own confirmed fixed position -- clicked directly rather than folded # into the blind Enter-press fallback, since we can verify it precisely. LESSON_REPORT_OK_BUTTON = (960, 895) LESSON_POST_SCHEDULE_MAX_ENTER_PRESSES = 8 # Arena / Tactical Challenge (module/arena.py). Fights a real ranked PvP # battle each run -- opt-in only, never in DEFAULT_ORDER (see # ba_auto/reference_notes/mapping.md's Arena row). Per explicit user decision # (2026-07-09): v1 fights exactly one battle per invocation, matching the # reference's own per-call pacing -- it relies on a persistent background # thread rescheduling itself 55 minutes later for the next ticket, which this # project's one-shot-per-invocation CLI has no equivalent for. # Policy knobs, carried over unchanged from the reference's own # core/config/default_config.py defaults. These have no live-UI dependency, # so they're safe to set now rather than waiting for calibration. ARENA_COMPONENT_NUMBER = 1 # which of the 3 visible opponent slots to challenge (1-3) ARENA_LEVEL_DIFF = 0 # accept an opponent up to this many levels above self (negative = only below) ARENA_MAX_REFRESH_TIMES = 10 # give up rerolling for an acceptable opponent after this many refreshes ARENA_STOP_FIGHT_WHEN_RANK1 = False # if True and current rank OCRs as 1, skip fighting and just collect rewards # Per explicit user direction (2026-07-12): arena.py now loops fighting # battles until the OCR'd ticket count reaches 0, rather than exactly one # battle per invocation (see arena.py's module docstring for the full # history of that earlier design and why it changed). ARENA_MAX_FIGHTS_ # PER_RUN is a defensive upper bound only -- not a hardcoded assumption of # the account's real daily ticket count (5, per the user, but read live via # OCR every run like everything else in this project) -- guarding against a # runaway loop if ticket-count OCR ever misreads persistently, matching the # project's established bounded-retry convention (OPEN_RETRIES, # RETURN_HOME_MAX_ROUNDS, etc.). ARENA_POST_BATTLE_COOLDOWN is the real # in-game lockout after a battle finishes before the next one can be # queued, per the user's explicit info -- not yet independently # live-confirmed against the exact UI symptom (a disabled button vs. a # genuinely unresponsive click) since the user described it directly rather # than this being discovered through live probing. ARENA_MAX_FIGHTS_PER_RUN = 10 ARENA_POST_BATTLE_COOLDOWN = 30 # Live-calibrated against nik-gpu on 2026-07-09 (see scratchpad/arena_calib_* # for the captured screenshots this was pixel-scanned/OCR-tested against). # This client does NOT expose Tactical Challenge as a bottom-nav icon like # the reference's main-page nav -- it's a card inside the お仕事 (Work) hub, # reached the same way story_sweep's 任務 card is (config.WORK_ICON). ARENA_WORK_HUB_CARD = (1310, 985) # 戦術対抗戦 card inside the WORK_ICON hub # "保有チケット N/M" readout, left info panel. OCR-confirmed live (read as # "5/5" against a real 5/5 balance) -- reference's own get_tickets splits on # "/" and keeps the first number, ported the same way in arena.py. ARENA_TICKET_OCR_RECT = (295, 780, 395, 812) # "N位" rank readout, left info panel, tight-cropped to exclude both the # rank-icon graphic (its own art was misread as spurious digits when # included, e.g. "29" -> "207") and the trailing "位" glyph. OCR-confirmed # live (read 29 against a real rank-29 display). Only needed if # ARENA_STOP_FIGHT_WHEN_RANK1 is True. ARENA_RANK_OCR_RECT = (208, 500, 285, 555) # Time reward (時間報酬, continuous income meter -- reclaimable repeatedly # as it re-accrues, confirmed live: button went claimable again within # seconds of a claim once 90/1,000K had re-accumulated) and daily reward # (デイリー報酬, genuinely once/day -- shows a countdown timer once claimed). # Click points sit on the button's own label text; probes are offset left # onto plain background fill, confirmed clean (no text) across every row of # a live pixel grid-scan in both claimed and claimable states. ARENA_TIME_REWARD_BUTTON = (525, 641) ARENA_TIME_REWARD_PROBE = (455, 640) ARENA_DAILY_REWARD_BUTTON = (525, 758) ARENA_DAILY_REWARD_PROBE = (455, 757) # Claimable = vivid gold fill; claimed/not-yet = flat neutral grey -- same # two-state pattern as the reference's own JP.json rgb_in_range check for # this exact feature, just recalibrated to this client's resolution/colors. # Pixel-sampled live across both reward slots and both states. ARENA_REWARD_CLAIMABLE_RGB = ((235, 205, 45), (255, 240, 95)) ARENA_REWARD_CLAIMED_RGB = ((200, 200, 200), (225, 225, 225)) # Season opponent list (3 rows). Row 1's click point is live-confirmed (it # opened the opponent-info modal below); rows 2/3 are extrapolated from the # same ~255px row spacing visible in scratchpad/arena_calib_05_after_close.png # but not yet individually click-confirmed. ARENA_OPPONENT_ROW_X = 900 ARENA_OPPONENT_ROW_Y = [400, 655, 910] # "リスト更新" -- refreshes all 3 shown opponents at once (matches the # reference's single refresh click for its 3 fixed slots, not a per-slot # reroll). Not yet click-confirmed. ARENA_REFRESH_LIST_BUTTON = (1750, 280) # Opponent-info modal ("対戦相手"). This client merges the reference's two # separate screens (opponent-info, then a distinct formation-edit/ # "攻撃編成" screen) into ONE modal that shows both the matchup and the # attack-formation button together. ARENA_MODAL_CLOSE_BUTTON = (1518, 207) # live-confirmed: closes with no ticket cost # navigation.is_modal_open's shared (960, 200) darkness probe does NOT work # here -- confirmed live it reads INVERTED for this specific screen: the # arena list's own background art at that point is already dark # (91, 113, 165), while the opponent-info modal's white card is bright # (247, 250, 252) there. Same probe point, opposite rule -- use r > 200 to # mean "modal open", not r < 150. ARENA_MODAL_PROBE = (960, 200) ARENA_MODAL_OPEN_MIN_CHANNEL = 200 # "攻撃編成" (Attack Formation) button -- this is the actual fight-commit # click; the modal shows a live ticket-count preview next to it (e.g. # "5→4") confirming it's what spends the ticket. Same gold fill family as # the reward buttons. Click-confirmed live 2026-07-09 (ticket went 5 -> 4, # landed on the attack-formation/squad screen below). ARENA_ATTACK_FORMATION_BUTTON = (958, 918) # Live-tested for real on 2026-07-09 (one real ticket spent, one real ranked # fight, WIN, rank 29 -> 21): see scratchpad/arena_fight_* for the captured # screenshots. Confirmed: # # - ARENA_ATTACK_FORMATION_BUTTON's click DOES work as the fight-commit step # (ticket went 5 -> 4) and lands on a "攻撃編成" squad-formation screen, # matching the reference's edit-force screen. # - "戦闘スキップ" (Battle Skip) was ALREADY ON by default on this account # (cyan checkmark confirmed via pixel-grid-scan) -- check_skip_button's # reroll-if-off logic still needs porting for accounts/states where it # isn't, but no live-confirmed "off" sample exists yet to calibrate that # state's color against. # - The actual fight button is "出撃" (Sortie), bottom-right of the # attack-formation screen, also bound to Enter (confirmed via keypress). ARENA_SORTIE_CONFIRM_KEY = "Return" # attack-formation screen's 出撃 button responds to Enter # IMPORTANT, live-discovered timing gotcha: after Sortie, the battle # auto-resolves (skip is on) and the game queues a "対戦結果" (Battle # Result) WIN/LOSE modal with a reward -- but it can take longer than a # couple seconds to actually render. A screenshot taken ~2s after the Sortie # keypress showed the arena list ALREADY updated (rank, ticket count, # wait-time cooldown) with NO result modal visible at all -- looking like # the reference's separate battle-win/battle-lost detection was unnecessary # here. That was wrong: the WIN modal was still pending and only rendered # on the NEXT click, confirmed by clicking an opponent row afterward and # getting the queued WIN screen instead. Do not assume "list looks normal # again" means the result was already handled. # # Two more real bugs were found live trying to precisely detect this # modal's own confirm button, both on 2026-07-09, using two of the # session's three real arena tickets: # # 1. WIN and LOSE modals are NOT the same height -- WIN shows a reward # showcase (credit points) above its confirm button, LOSE doesn't, so # LOSE's confirm button sits noticeably higher on screen (~y 710-810 vs # WIN's ~y 815-870). A fixed probe point calibrated only against WIN # missed LOSE entirely, which also made reward collection silently read # "not claimable" right after -- it was actually checking the reward # buttons while the still-undetected LOSE modal was covering them, not a # real reward-state problem. # 2. Widening the search into a region spanning both button positions (the # same fix story_sweep.py's own two-position SKIP/OK button used) fixed # that, but then a live rerun found the region also caught stray # cyan-ish pixels from the opponent list's own portrait art -- a false # centroid match there was clicked and opened a completely unrelated # opponent's "対戦相手" info modal instead of confirming anything. A # narrower region (offline-validated against every saved WIN/LOSE/ # opponent-info/plain-list screenshot before risking the session's last # ticket on it) still detected the *same* stuck state on the very next # live run -- the false-positive source is fundamentally unpredictable # per-refresh portrait art, not something a fixed region can rule out. # # Given the opponent-info modal's OWN attack-formation button responds to # Enter and spends a real ticket, the close call here is real: this project # already has a documented precedent (see CLAUDE.md's story_sweep writeup) # for exactly "a mistimed keypress could have started a real battle." # # Fixed properly by abandoning per-button color detection entirely. # ba_auto/tasks/arena.py's _wait_for_result instead presses Enter -- # confirmed to be the universal safe dismiss for both modals and for an # unrelated "list refresh expired" notice that can also appear -- in a # bounded blind-retry loop, the same pattern lesson.py's _run_one_schedule # already uses for its own "variable sequence of post-action screens" # problem. The one genuine hazard (the opponent-info modal's Enter-bound # attack-formation button) is guarded by a HARD gate checked before every # single press: if that specific modal is ever detected (via its own gold # button at ARENA_ATTACK_FORMATION_BUTTON, a fixed, reliable, non-color- # region check), the function stops immediately without pressing Enter, # rather than risk spending a second ticket. ARENA_RESULT_CONFIRM_KEY = "Return" # universal safe dismiss for both WIN/LOSE and the list-refresh-expired notice # Self/opponent level OCR, for choose_enemy's reroll logic -- read directly # from the list screen (matching the reference's own self_level_region/ # opponent_level_region, both read there before any modal opens), not from # inside the opponent-info modal. Live-calibrated 2026-07-09: the naive # tight crop (just the "90" glyphs) OCR'd as None even with a correctly # time-matched screenshot -- turned out to be a real crop-size problem, not # a stale-screenshot one this time. Debugged by dumping the exact # thresholded image tesseract sees (scratchpad/probe_arena_level_ocr*.py): # the tight crop was legible to the eye but too marginal for tesseract at # 3x upscale, succeeding on some rows/psm modes and not others. A few extra # pixels of padding on each side fixed it outright across every row, still # using the existing 3x OCR_UPSCALE pipeline -- no pipeline change needed, # just a less tight crop. ARENA_SELF_LEVEL_OCR_RECT = (288, 342, 335, 383) # white-on-dark -- use read_int_white_on_dark ARENA_OPPONENT_LEVEL_OCR_RECTS = [ # dark-on-light -- use plain read_int (735, 498, 782, 533), (735, 736, 782, 771), (735, 973, 782, 1008), ] # Battle-skip toggle on the attack-formation screen (see # ARENA_ATTACK_FORMATION_BUTTON above -- this is the screen it lands on). # Pixel-grid-scanned live against a real ON state (cyan checkmark); no OFF # sample has been seen yet since it was already on by default this session, # so ARENA_SKIP_ON_RGB is only confirmed to correctly detect "on", not yet # confirmed to correctly reject a real "off" state. ARENA_SKIP_TOGGLE_PROBE = (1670, 1020) ARENA_SKIP_TOGGLE_CLICK = (1670, 1020) ARENA_SKIP_ON_RGB = ((60, 205, 235), (140, 255, 255)) # Still entirely unknown / unexercised: # - the LOSE variant of the result modal # - ARENA_REFRESH_LIST_BUTTON has not actually been click-confirmed -- this # session's one live fight never needed a reroll (the chosen opponent was # already an acceptable level), so choose_enemy's refresh path is # implemented per the reference but not yet exercised live # - template images (see detector.find_template) for best-record/ # season-record or any other screen this client might show between # confirming attack formation and the fight resolving, if one ever # appears (none did across this one live fight) # Bounty (指名手配, module/rewarded_task.py). Live-calibrated against # nik-gpu 2026-07-11, zero real tickets spent -- every confirm dialog reached # was cancelled via Escape, ticket count (6/6) confirmed unchanged # before/after across all 3 areas. See ba_auto/tasks/bounty.py's module # docstring for the full design writeup (per explicit user direction: choose # one of 3 areas via the same date-ordinal-modulo rotation story_sweep.py/ # event_sweep.py already use, always sweep that area's latest/highest stage). # Work-hub card entry point (WORK_ICON, defined above -> this card). Lands # directly on the Location Select screen -- no separate bus-icon/ # sub-navigation step needed on this client, unlike the reference's # bottom-nav "main_page_bus" icon. BOUNTY_CARD = (1113, 645) # Location Select's 3 fixed area rows -- all 3 fit on screen with no # scrolling needed. Index 0/1/2 = ハイウェイ/砂漠の線路/校舎, this client's # rendering of the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM (matched by # each area's own recommended-school grouping, e.g. ハイウェイ's # ゲヘナ/山海経/ヴァルキューレ/ハイランダー lining up with the reference's # bounty_name[0] = "OVERPASS" grouping). BOUNTY_AREA_ROW_Y = (300, 475, 650) BOUNTY_AREA_ROW_X = 1400 BOUNTY_AREA_NAMES = ("ハイウェイ", "砂漠の線路", "校舎") # Stage list, forced to its bottom scroll extreme every run (not just # trusted to already be there -- see module docstring). Confirmed live # across all 3 areas: exactly 10 stages per area (numbered 01-10, lettered # A-J), row 10 is the true list end (scrolling further is a no-op), and # every stage was already 3-starred/SSS-cleared. This makes the bottom-most # row always literally "the latest stage available" by construction -- no # OCR search needed to locate a specific target the way story_sweep/ # event_sweep need (their target lists have more entries than fit on screen # at once; this one doesn't). BOUNTY_STAGE_LIST_SCROLL_POINT = (1400, 700) BOUNTY_STAGE_LIST_SCROLL_CLICKS = 25 BOUNTY_LATEST_STAGE_ROW_Y = 1086 BOUNTY_STAGE_ENTER_X = 1685 # Stage-number OCR crop for the bottom-most row (e.g. "10"), diagnostic/log # only -- selection is always "bottom-most row," not a text match, so this # isn't on the decision path. Offset from BOUNTY_LATEST_STAGE_ROW_Y. BOUNTY_STAGE_NUMBER_OCR_X = (1040, 1120) BOUNTY_STAGE_NUMBER_OCR_Y_PAD = (35, 35) # 任務情報 (task info) modal. MAX button / minus-stepper-probe / start-sweep # button positions and colors confirmed live pixel-identical to # EVENT_SWEEP_MAX_BUTTON/EVENT_SWEEP_MINUS_BUTTON_PROBE/ # EVENT_SWEEP_START_BUTTON (including the exact same raised-vs-default # minus-button colors, (171,172,171) vs (251,173,152)) -- kept as separate # BOUNTY_-prefixed constants anyway to avoid coupling the two tasks' # config together, matching this file's own established pattern (see # EVENT_STAGE_MODAL_PROBE's comment for the earlier instance of the same # reasoning). BOUNTY_SWEEP_MIN_BUTTON = (1178, 511) BOUNTY_SWEEP_MAX_BUTTON = (1631, 511) BOUNTY_SWEEP_PLUS_BUTTON = (1517, 511) BOUNTY_SWEEP_MINUS_BUTTON_PROBE = (1281, 511) BOUNTY_SWEEP_START_BUTTON = (1400, 668) # Modal-open probe. NOT reused from EVENT_STAGE_MODAL_PROBE: that corner # point (1870, 600) sits on this screen's own dark-blue background # REGARDLESS of whether the modal is open (confirmed live: both states read # dark there), since this screen's underlying art differs from event_sweep's # -- it can't tell the two states apart here. (1850, 1150) does: confirmed # live dark (<150 all channels) with the modal open, bright # (160,203,225-ish) with it closed, both directions checked. BOUNTY_STAGE_MODAL_PROBE = (1850, 1150) BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL = 150 # Modal close. Confirmed live this modal DOES close on Escape (like # event_sweep's, unlike story_sweep's) -- the X button position is kept as a # fallback, matching event_sweep.py's own close pattern. BOUNTY_STAGE_MODAL_CLOSE_BUTTON = (1691, 271) # The ticket-usage confirm dialog ("指名手配チケットをN使用して、掃討をN回 # 行いますか?") is the exact same shared "通知" dialog component as the # AP-usage-confirm dialog -- confirmed live pixel-identical position and # color to SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/ # SWEEP_CONFIRM_CYAN (defined above under story_sweep), reused directly. # SWEEP_CONFIRM_GOLD's use as this same dialog's "can't proceed" variant is # now live-confirmed too, just via a different trigger than expected: not # insufficient tickets at the 掃討開始 click (never observed -- this account # always had enough tickets for the count requested), but a real Pyroxene # "指名手配チケット購入" (buy more tickets) prompt that appeared automatically # after a sweep dropped the ticket count to exactly 0 (2026-07-11 live test). # Same button position/color, confirmed live -- no Pyroxene was actually # spent (gem balance unchanged before/after), and _watch_sweep_result now # has its own explicit guard against this state -- see its comment. # Post-sweep result screen ("掃討完了"). Live-confirmed 2026-07-11 (count=1, # the only value possible with the 1 ticket available for this test): a # SINGLE "OK" button dialog (no separate SKIP step -- unlike story_sweep/ # event_sweep's SKIP-then-OK sequence, though that may be specific to a # single-round sweep; a bulk/MAX sweep's result flow is NOT yet confirmed to # match). Pixel-scanned real OK-button bbox: x 783-1139, y 879-991. # # The FIRST live test of this (also 2026-07-11) used a naive copy of # EVENT_SWEEP_RESULT_BUTTON_REGION (1000-1300 x, 700-1050 y) and hit a real, # confirmed-live false positive: that region's y-range overlapped # BOUNTY_SWEEP_START_BUTTON's own real cyan pixels (掃討開始 spans y # 622-715), so once the real result dialog was dismissed and the plain # stage-info modal reappeared (with 0 tickets left), _find_result_button # re-matched 掃討開始's corner and clicked it -- which is what triggered the # real ticket-purchase prompt described above. Narrowed here (matching # EVENT_SWEEP_RESULT_BUTTON_REGION's own history of being fixed the same # way after its own live false-positive) to a tight box around the # confirmed real OK-button bbox, comfortably clear of 掃討開始's y-range. BOUNTY_SWEEP_RESULT_BUTTON_REGION = (750, 850, 1160, 1010) # A SECOND, smaller contamination source was also found live within the # corrected region above: the modal's own "獲得期待報酬" reward-icon artwork # (visible whenever the plain stage-info modal is showing, sitting at the # same y-position as the result dialog's OK button) has a handful of pixels # that incidentally fall inside SWEEP_CONFIRM_CYAN's broad range too -- # confirmed live at ~878 stray pixels vs the real OK button's ~34,000. # find_color_centroid's min_pixels parameter (see detector.py) filters this # out; set well below the real button's count and well above the confirmed # noise floor, with wide margin on both sides for icon-art variance across # different stages/areas this project hasn't captured yet. BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000 # Which area to sweep today, per explicit user request (2026-07-11): "3 # areas, choose random (mod%3), run the latest stage available." Same # date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*. BOUNTY_SWEEP_COUNT = "max" # Real-usage hazard, reported live 2026-07-17: the 任務情報 (task info) # modal has TWO separate action buttons stacked vertically -- the cyan # 掃討開始 (start sweep, safe, instant) this task intends to click, and a # separate gold 任務開始 (start mission, a REAL manual battle) directly # below it, both showing an identical "N→N-1" ticket-cost preview tooltip. # A real run's log showed `_click_sweep_start_and_verify` succeeding # cleanly (no retry messages) right after two failed # `_click_max_and_verify` attempts, followed by `_watch_sweep_result` # eventually reporting "swept" -- but the account was left showing a real # "Battle Complete" result screen (a live combat timer, ~3 minutes, visible # in a follow-up screenshot) instead of having done an instant sweep, and # every task that ran afterward failed to open its own screen for the rest # of that `daily` run. The exact mechanism that let this happen was not # fully reproduced live (doing so would mean deliberately repeating a real # battle), but every check along this path # (_count_raised_above_one/_is_sweep_usage_confirm/_watch_sweep_result's # own "swept" conditions) is a generic color/position probe with no check # on WHAT is actually showing -- any of them could plausibly be satisfied # by an unexpected screen (including a real battle's own UI) the same way # login.py's single/few-point checks were repeatedly fooled by unexpected # splash-art frames the same day (see plan.md's Phase 18 follow-up #3). # # Fixed with an OCR text check (_confirm_dialog_is_sweep in bounty.py) # gating the FINAL, irreversible SWEEP_CONFIRM_BUTTON click -- ported # directly from the same real, live-captured dialog # ("指名手配チケットをN使用して、掃討をN回行いますか?", captured # 2026-07-17 by reaching the real confirm dialog and cancelling before # confirming, the same safe-calibration pattern the original Phase 15 # bounty work used throughout) rather than trusting SWEEP_CONFIRM_CYAN's # color match alone to mean "this is definitely the sweep confirm dialog # and not some other cyan-styled confirmation." BOUNTY_SWEEP_CONFIRM_TEXT_RECT = (605, 505, 1320, 615) # Gem shop daily free package (毎日無料パッケージ), ported from # module/collect_daily_free_power.py. Reference reads: home-screen icon -> # purchase-pyroxenes dialog -> パッケージ (package) tab -> the FREE card at a # fixed position -> confirm-purchase notice -> reward. The reference detects # every step via image template matching (no OCR anywhere in this flow) -- # this port uses plain color probes instead, matching this project's own # established equivalent for simple enabled/disabled or state-A/state-B # visual differences (see cafe.py's CLAIM_DISABLED_RGB, stamina.py's # MISSION_CLAIM_PROBE) rather than building new template assets, since every # state below reduces to a clean, high-contrast flat color rather than a # complex shape needing find_cafe_sparkle-style matching. # # RECALIBRATED 2026-07-29 after a real Blue Archive client UI update changed # this screen's whole layout. Re-verified live on nik-gpu the same day (see # scratchpad/gem_shop_after_click.png / gem_shop_weekly_tab.png / # gem_shop_after_close.png). What changed from the original 2026-07-15 # calibration: # - The 期間限定/青輝石/パッケージ 3-top-tab layout is GONE. The dialog is # now a real full subscreen (own header + top-left back arrow at the # same (85, 55) navigation.BACK_BUTTON every other subscreen uses, not # an overlay on top of home), with a left sidebar # (おすすめの商品/限定商品/一般商品/定額商品/青輝石/特別支援) and, for # 一般商品 specifically, a further デイリー/ウィークリー/マンスリー # switch top-right. The daily free package lives under # 一般商品 -> デイリー. # - Because it's now a genuine subscreen, navigation.is_on_subscreen # correctly reads True here (confirmed live: SUBSCREEN_HEADER_PROBE read # (247,250,252) on this screen vs (50,57,137) on true home) and # navigation.return_to_home correctly closes it (confirmed live) -- the # old GEM_SHOP_DIALOG_PROBES workaround below is no longer needed at all # and has been removed; gem_shop.py now shares the same # is_on_subscreen/return_to_home machinery as mailbox/cafe/shop/lesson. # - The status-bar/button coordinates all moved (card is taller, sits # higher). GEM_SHOP_STATUS_CLAIMED_RGB was re-read directly off today's # real "already claimed" state. GEM_SHOP_STATUS_AVAILABLE_RGB could NOT # be re-read off the free card itself (already claimed for the day by # the time of this recalibration) -- instead it was read off three # other real, currently-purchasable cards under the ウィークリー tab # (レポートパッケージ/レポートパッケージ(Lite)/強化珠パッケージ, all # three independently reading the identical (226,236,246)), since Blue # Archive's shop UI reuses one shared "N times purchasable" status-bar # style across every card in this screen, not a per-card color. This is # an inferred-but-live-observed value, not a guess -- treat as # implemented-but-not-yet-confirmed against the daily free card itself # specifically, same disclosed-gap shape the original calibration had # for the "available -> claim" path in general. Re-confirm the exact # first time gem_shop actually claims a package post-redesign. GEM_SHOP_ICON = (204, 345) # 一般商品 (general products), 3rd item in the left sidebar # (おすすめの商品/限定商品/一般商品/定額商品/青輝石/特別支援). Always # clicked explicitly rather than relying on it already being selected -- # even though it was the landing tab in this recalibration, per this # project's own established discipline of never trusting a remembered/ # default UI selection before a real click (see memory: BA count/quantity # steppers remember last-used values; the same caution applies to any # selected-tab state here). GEM_SHOP_GENERAL_PRODUCTS_TAB = (196, 376) # デイリー (daily), 1st of 3 sub-tabs (デイリー/ウィークリー/マンスリー) # shown top-right once 一般商品 is open. Also always clicked explicitly for # the same reason as GEM_SHOP_GENERAL_PRODUCTS_TAB above. GEM_SHOP_DAILY_SUBTAB = (1330, 148) # 毎日無料パッケージ card's own 購入 (purchase) button -- the only card # shown under 一般商品 -> デイリー (fixed position, no scrolling/search # needed). GEM_SHOP_FREE_CARD_BUY_BUTTON = (576, 630) # The free card's own status bar (just below its artwork, above the price # button) reads a flat, highly distinct color depending on claim state -- # see the RECALIBRATED note above for how each value was obtained: # available ("一日にN回まで購入可能"): flat light blue ~(226, 236, 246) # claimed ("一日に0回まで購入可能"): flat dark red ~(190, 56, 66) # The two are far enough apart (light vs dark, blue-family vs red-family) # that no OCR of the "0"/"N" count text is needed -- the reference itself # doesn't OCR this either, it template-matches two whole separate # "purchasable"/"non-purchasable" card images. GEM_SHOP_FREE_CARD_STATUS_PROBE = (410, 485) GEM_SHOP_STATUS_AVAILABLE_RGB = (226, 236, 246) GEM_SHOP_STATUS_CLAIMED_RGB = (190, 56, 66) GEM_SHOP_STATUS_TOLERANCE = 20 GEM_SHOP_ICON_RETRIES = 3 # Bounds the "press Enter, re-check the free card's status" loop that # advances through the confirm-purchase notice and the "報酬獲得!" reward # banner after clicking 購入 -- confirmed live (pre-redesign) the reward # banner's own entry animation did not accept input on the first 1-2 # presses while its sparkle animation was still playing, so this needs real # patience, not just 1-2 tries. Not yet re-confirmed against the redesigned # confirm/reward notice specifically -- see gem_shop.py's module docstring. GEM_SHOP_CLAIM_MAX_ATTEMPTS = 6 # Circle (サークル / guild), ported from module/group.py. Home -> bottom-nav # ソーシャル (Social) icon -> サークル (Circle) card, opening the circle's # chat/member screen. Live-calibrated 2026-07-15 on nik-gpu (real account, # already a circle member -- "not in a circle" per the reference's own # group_join-club outcome was not exercised or ported, see circle.py's # module docstring). SOCIAL_ICON = (812, 1080) # サークル card, leftmost of three (サークル/フレンド/助っ人) on the # ソーシャル hub page. CIRCLE_CARD = (463, 613) # Login flow: the "TOUCH TO START" title screen through whatever one-off # daily popups appear (a real network-hiccup notice, the daily attendance # card, an infrequent welcome-back login bonus, S.C.H.A.L.E NEWS) to the # true home screen. Ports the reference's core/Baas_thread.py::to_main_page # (its own generic post-launch arrival routine -- co_detect reacting to # ~20 named one-off img_reactions/rgb_possibles until the 'main_page' rgb # state is reached) plus module/restart.py's kill-and-relaunch-if-stuck # pattern. See ba_auto/tasks/login.py's module docstring for the full # live-calibration writeup, including a real stuck-loading incident hit # during calibration itself. # # All coordinates/colors below are pixel-scanned from real scrot captures # on nik-gpu at the native 1920x1200, not the non-native-resolution # screenshots/daily_login/*.png reference photos the user originally # supplied (same "not 1:1 with real game coordinates" finding already # documented for screenshots/gem_shop/ and screenshots/cafe/student/). GAME_PROCESS_NAME = "BlueArchive.exe" GAME_LAUNCH_SCRIPT = "/usr/local/bin/launch-blue-archive.sh" LOGIN_TOUCH_TO_START = (960, 1060) # S.C.H.A.L.E NEWS popup's own X close button. LOGIN_NEWS_CLOSE_BUTTON = (1710, 215) # The ブルーアーカイブ logo (top-left) is fixed UI chrome, independent of # the title screen's own rotating seasonal background art -- confirmed # live across two completely different background pieces (a beach BBQ # scene and a train-interior scene) reading the exact same RGB at every # probe point. It reads one of three ways: # bright cyan (0, 215, 250) -- clean title screen, tap to proceed # dimmed cyan (0, 97, 114) -- a notice/dialog open on top of the title # screen (live-confirmed: a real "network # connection failed" error), Enter dismisses # neither -- title screen has been left entirely # (loading, the attendance card, home, etc.) # Multi-point (not single-pixel), matching this project's established # multi-point-beats-single-point technique (navigation.is_header_bar_visible, # gem_shop's GEM_SHOP_DIALOG_PROBES) -- confirmed against every other # captured state (loading screen, attendance card, true home) to avoid # false-matching a background art color that coincidentally lands in range # at any single one of these points. LOGIN_LOGO_PROBES = ((100, 120), (300, 120), (320, 100)) LOGIN_LOGO_BRIGHT_RGB = (0, 215, 250) LOGIN_LOGO_DIMMED_RGB = (0, 97, 114) LOGIN_LOGO_COLOR_TOLERANCE = 20 # S.C.H.A.L.E NEWS popup's own header bar -- a solid, distinctive blue # spanning its full width. navigation.is_modal_open/is_on_subscreen both # proved unreliable here (same class of default-probe mismatch as # gem_shop.py's own dialog, see GEM_SHOP_DIALOG_PROBES): the dialog # overlays home directly, and is_modal_open's single probe point happens to # land on the dialog's own bright header/body rather than a dimmed # backdrop, so it reads not-dark (i.e. "no modal") whether the dialog is # open or not -- confirmed by direct pixel comparison against the closed # state at the same points. LOGIN_NEWS_HEADER_PROBES = ((500, 215), (700, 215), (900, 215), (1100, 215), (1300, 215)) LOGIN_NEWS_HEADER_RGB = (30, 155, 248) LOGIN_NEWS_HEADER_TOLERANCE = 35 # Positive confirmation that the bottom nav bar (カフェ/スケジュール/...) # is showing: a flat, near-white, tightly-clustered background between the # icons. Needed because navigation.is_on_subscreen/is_modal_open are BOTH # calibrated only to distinguish in-game states from each other -- neither # was ever designed to rule out the pre-login title screen or the daily # attendance card, and a real live test (2026-07-16) found both of those # states also read "not a subscreen, no modal open", the same false/false # pattern as true home, causing login.py's very first home-check to return # a false positive while still sitting on the title screen (a second run # hit the same false positive while still on the unclaimed attendance # card, which would have silently skipped that day's reward). This # multi-point check (min channel + max spread, not a single RGB target) # was confirmed live to uniquely hold on true home and fail on every other # captured state (both title-screen background arts, the attendance card, # and home with the news dialog still open and dimming this same area). # Promoted to navigation.is_home_nav_bar_visible/HOME_NAV_BAR_* (2026-07-31) # once battle_pass.py needed the same positive signal for the same reason # -- see that module's docstring. Matches navigation.py's own convention of # owning its probe constants locally rather than sourcing them from here. LOGIN_POLL_INTERVAL = 2 # Generous per-attempt budget: a normal run (title tap -> loading -> # attendance card -> home) completed in well under 15s during live # calibration, but a real stuck-loading incident during that same session # ran past 6 minutes with zero progress before a manual kill+relaunch was # needed -- wide margin above the normal case, still well under the # reference's own 600s co_detect default timeout. LOGIN_TIMEOUT_SECONDS = 240 LOGIN_MAX_RELAUNCHES = 2 LOGIN_KILL_WAIT_ATTEMPTS = 10 # ~20s for the old process to fully exit LOGIN_RELAUNCH_WAIT_ATTEMPTS = 45 # ~90s+ for the window to reappear # The full-bleed loading transition (rotating splash art, no chrome at all -- # see login.py's module docstring, state 3's "second variant") renders a # small gray spinner badge dead center on screen regardless of which splash # art frame is showing behind it. Live-captured 2026-07-19 during a real # stuck instance (the user ran `ba_cron_run.sh daily` manually and reported # the game stuck at login -- screenshots/daily_login/stuck_loading_buffer.png, # calibrated via scratchpad/probe_login_buffer.py): the badge's left/right edges read a # flat, exact neutral gray (118,118,118) -- R==G==B, unlike any of the # colorful splash-art pixels sampled elsewhere on the same screenshot # (all clearly non-gray, e.g. (216,233,207), (107,103,158)) -- across the # whole sampled y-range at x=928 and x=992 (the badge spans roughly # x=[928,992], y=[571,627], centered on the screen's own true center, # (960,600)). Deliberately samples only these flat edge columns, not the # badge's own interior (which has a white spinner icon washing out some # interior pixels to near-white) -- same "avoid the part that visibly # varies" reasoning as every other multi-point probe in this project. LOGIN_LOADING_BUFFER_PROBES = ((928, 580), (928, 600), (928, 620), (992, 580), (992, 600), (992, 620)) LOGIN_LOADING_BUFFER_RGB = (118, 118, 118) LOGIN_LOADING_BUFFER_TOLERANCE = 12 # How long this exact badge must be seen continuously before treating it as # stuck rather than a normal (if slow) loading transition -- the "brief" # loading variant in login.py's docstring resolved in a few seconds during # calibration, and there's no real data on how long the full-bleed variant # normally takes when it ISN'T stuck, so this stays well above that to avoid # killing a genuinely-progressing load. Far shorter than the generic # LOGIN_TIMEOUT_SECONDS=240 blind wall-clock budget, though, since this is a # specific, well-understood bad state (not "anything unrecognized") -- no # reason to wait the full 4 minutes once it's confidently identified. LOGIN_LOADING_BUFFER_STUCK_SECONDS = 60 # Battle Pass (バトルパス) -- ported from baas-reference's # module/collect_pass_reward.py. See ba_auto/tasks/battle_pass.py's module # docstring for the full reference-mapping writeup; constants below were # all calibrated live 2026-07-29 at this project's usual 1920x1200. # Home-screen entry banner (bottom-left promo card, e.g. "シノンのバトル # パス SEASON 3") -- a home-screen entry point outside the bottom nav bar, # same idea as arena's WORK_ICON->TASK_CARD. Confirmed live: clicking here # opens the pass menu (after a brief loading splash). BATTLE_PASS_HOME_BANNER = (530, 935) # "ミッション" (Mission) button inside the pass main menu, bottom-left. BATTLE_PASS_MISSION_BUTTON = (555, 1080) # Neither navigation.is_on_subscreen nor is_modal_open fire correctly on # either battle-pass screen -- both read exactly like true home (no bright # subscreen header, no dark modal backdrop), confirmed live 2026-07-29. # This project-local probe instead checks a row of points along the # bottom edge (y=1190) that both battle-pass screens render as a flat, # fixed dark band (53,58,76) at EVERY sampled x, regardless of which # splash art or sub-screen is showing above it -- confirmed identical # across the pass main menu, the mission sub-screen, and both their # post-claim states. The true home screen has no such fixed band there at # all (it's character art all the way to the edge), so every sampled x # reads a different, brighter value instead. Used both to confirm the # pass menu opened at all, and, in the exit loop, to confirm we're still # inside SOME battle-pass screen (as opposed to home) without caring which. BATTLE_PASS_INSIDE_PROBES = ((10, 1190), (400, 1190), (960, 1190), (1500, 1190), (1900, 1190)) BATTLE_PASS_INSIDE_RGB = (53, 58, 76) BATTLE_PASS_INSIDE_TOLERANCE = 12 # Distinguishes the mission sub-screen specifically from the pass main # menu (both satisfy BATTLE_PASS_INSIDE_PROBES above identically, since # they share the same footer chrome). The mission screen's own tab bar # ("全体/デイリー/ウィークリー/実績") renders a near-white active-tab # background spanning both these points; the pass main menu's own tab bar # ("一般CH/成長CH") sits further right and doesn't reach either x, leaving # whatever the character art renders there instead -- confirmed live, # clearly not white at either point on the pass main menu. BATTLE_PASS_MISSION_TAB_PROBES = ((1080, 60), (1450, 60)) BATTLE_PASS_MISSION_TAB_MIN_CHANNEL = 245 # Same bright-yellow-vs-grey "一括受取" (claim-all) signature as # stamina.py's MISSION_CLAIM_PROBE, confirmed live at this exact point -- # shared by BOTH battle-pass screens (the mission screen's own claim-all # button, and the pass main menu's own level-reward claim-all button, # render at the identical position). Has its own Enter keybind shown on # 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"