Nik Afiq 68a7ed2529 feat: cafe lowest-affection invite for room 2, exit_game AP-left-unspent alert, login window-flicker fix
- cafe.py: room 2 now invites the lowest-affection candidate instead of
  highest (room 1 unchanged), plus OCR'd invited-student name logging
- exit_game.py: alert (AP_ALERT: log tag, picked up by ba_cron_run.sh's
  Discord alerting) if AP is still unspent above threshold when the game
  closes
- login.py: re-check window_exists() after the stabilization wait to catch
  a startup window-flicker race that could crash a run
- config.py/mapping.md: supporting constants and reference-mapping updates

Also rewrites plan.md: retires the phase-by-phase changelog (now fully
superseded by CLAUDE.md's own documentation) down to forward-looking
backlog items, and adds a performance-improvement plan from a cron-log
time analysis (lesson.py's per-cell screenshot redundancy, event_sweep's
multi-call design).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 19:28:59 +09:00

1816 lines
108 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 <task>` 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: R<G on all
# samples, e.g. (45,70,99); badge-pink pixels sampled R>G, 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)
# Name text OCR rect, offset from row_y -- pixel-calibrated live 2026-08-13
# against a real open invite list (scratchpad/probe_invite_list.png, since
# deleted): the name sits directly above the heart badge, right of the
# portrait. (700, 1050) on X clears the portrait on the left and stays well
# short of the 招待 button on the right; (-70, -15) on Y (offset from
# row_y) was confirmed by direct crop inspection against 5 real rows of
# varying name length (short "ミカ" through longer "ナギサ(水着)") with
# no clipping and only a harmless sliver of the heart badge's own top edge
# creeping into the very bottom of the crop.
CAFE_INVITE_NAME_RECT_X = (700, 1050)
CAFE_INVITE_NAME_RECT_Y_OFFSET = (-70, -15)
# 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<N>使用して、掃討を
# <M>回行いますか?" 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
# exit_game alerts (via ba_cron_run.sh's AP_ALERT log-grep, see plan.md
# Phase 30) if AP is still above this when the game closes -- catches AP
# quietly capping out unspent between cron fires. A separate constant from
# SWEEP_MIN_AP above even though the value happens to match: that one is a
# feasibility floor for attempting a sweep, this one is a "you left AP on
# the table" waste threshold, and they're free to diverge independently.
EXIT_AP_ALERT_THRESHOLD = 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 <region>-<stage>の挑戦回数が不足しています。挑戦回数
# を回復させますか?", "あと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/<current_event>.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
# Pending-update / additional-data download notice -- a real "お知らせ"
# (Notice) dialog that can appear immediately after launch, well before the
# title screen, reading "必要なコンポーネントをダウンロードします。" with a
# "容量"/MB size box and キャンセル/OK buttons. This is the same notice
# class as the reference's own main_page_game-download-resource-notice[23]/
# main_page_download-additional-resources img_reactions in
# core/Baas_thread.py::to_main_page, which the reference answers with a
# fixed-coordinate click on a matched image template. This project has no
# template asset for it, so OCR on the body text is used instead (matching
# CLAUDE.md's OCR policy) -- see login.py's module docstring for the full
# live-incident writeup (a real q4h cron run got stuck here 2026-08-04
# because this dialog previously fell through to the generic Enter
# fallback, which does NOT dismiss it, unlike every other one-off notice
# this flow has hit so far).
#
# Live-captured on nik-gpu at the native 1920x1200
# (scratchpad/login_probe_01.png). LOGIN_UPDATE_DIALOG_TEXT_RECT crops the
# dialog's own body line; lang="jpn" OCR read it back as
# "必要なコンボーネントをダウンロードします。" (a minor ポ->ボ misread,
# harmless -- LOGIN_UPDATE_DIALOG_KEYWORD only checks for the substring
# "ダウンロード", which read back correctly). LOGIN_UPDATE_OK_BUTTON is a
# point on the OK button's own bright-cyan fill (confirmed via a pixel scan
# across the button's full width, (107,216,253)-(122,222,255) throughout
# except two x-columns landing on the dark-navy "OK" glyph strokes
# themselves) -- deliberately not the button's visual center, to stay clear
# of the text.
LOGIN_UPDATE_DIALOG_TEXT_RECT = (600, 420, 1320, 465)
LOGIN_UPDATE_DIALOG_KEYWORD = "ダウンロード"
LOGIN_UPDATE_OK_BUTTON = (1040, 851)
# 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"