Nik Afiq a30c3888d6 fix: address multiple bugs causing incorrect battle handling and result screen issues
- In arena.py, widened RESULT_MODAL_MAX_POLLS to 200 to accommodate real battle durations and added early exit check in _wait_for_result to prevent stuck screens.
- Updated _fight_one to properly handle _wait_for_result's return value, ensuring failures stop the fight loop.
- In bounty.py, implemented OCR verification in _confirm_dialog_is_sweep to ensure the correct confirmation dialog is displayed before proceeding with sweeps, preventing unintended battles.
- Documented hazards and fixes in plan.md, detailing incidents from 2026-07-17 that led to these changes.
2026-07-18 12:31:42 +09:00

1303 lines
76 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"
ASSET_DIR = os.path.expanduser("~/ba_assets")
# Runtime working files (probe/detection screenshots); never /tmp, per CLAUDE.md.
SCRATCHPAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scratchpad")
os.makedirs(SCRATCHPAD_DIR, exist_ok=True)
# 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)
# 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)
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.
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)
# 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)
# (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 = 30
STORY_SWEEP_ROTATION_STAGE_COUNT = 6
STORY_SWEEP_ROTATION_COUNT = "max"
# 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. This rect
# and the lang="jpn" OCR read against it are LIVE-CONFIRMED both ways
# (2026-07-10, against "嵐過天晴", a genuinely finished event reachable via
# the badge carousel's 2nd dot position at that time): the OCR returned the
# exact phrase verbatim on the finished-event page (`detector.read_text`
# with psm=6), and on the correct/current event's page the same rect reads
# unrelated stage-list text with no false-positive "終了" substring match.
# 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.
EVENT_FINISHED_TEXT_RECT = (1030, 600, 1810, 750)
# 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)
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)
# (row, col, item name (log/debug only -- identification is by grid
# position, see shop_utils.py's module docstring for why), expected
# credit-point price). Buy-list confirmed with the user; all 8 are visible
# without scrolling.
COMMON_SHOP_TARGETS = [
(0, 0, "初級レポート", 12500),
(0, 1, "中級レポート", 125000),
(0, 2, "上級レポート", 300000),
(0, 3, "最上級レポート", 500000),
(1, 0, "初級強化珠", 10000),
(1, 1, "中級強化珠", 40000),
(1, 2, "上級強化珠", 96000),
(1, 3, "最上級強化珠", 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.
#
# All coordinates/colors below are pixel-scanned from real scrot captures on
# nik-gpu at the native 1920x1200 (not estimated from the non-native-resolution
# screenshots/gem_shop/*.png reference photos -- confirmed those are NOT 1:1
# with real game coordinates, same finding as screenshots/cafe/student/'s own
# calibration history). Live-calibrated 2026-07-15 by manually driving the
# real dialog end-to-end (see scratchpad/gem_shop_*.png for the captures) --
# this walk-through also genuinely claimed the account's real free package
# for the day (0 yen, +10 AP / +10,000 credits, confirmed via before/after
# counter values), so the two states below are both real, not guessed.
GEM_SHOP_ICON = (204, 345)
# パッケージ (package) tab, third of three (期間限定/青輝石/パッケージ).
#
# KNOWN FUTURE-BREAKAGE RISK (flagged in review, not yet fixable): this is a
# single fixed coordinate calibrated only against the CURRENT 3-tab layout,
# where 期間限定 (time-limited) is present as the first tab. The reference's
# own to_purchase_type branches on exactly this -- it picks a different
# パッケージ click x depending on whether 期間限定 is currently showing,
# because removing it reflows the remaining tabs. screenshots/gem_shop/2.png
# (captured live the same day as this calibration) shows 期間限定's own
# on-screen countdown ("終了まであと6日" / "26.06.24〜26.07.29"), meaning
# this account's 3-tab layout is itself temporary and due to change around
# 2026-07-21 -- not a theoretical edge case. No real screenshot of the
# resulting 2-tab (青輝石/パッケージ) layout exists yet to calibrate the
# correct alternate x, so a second coordinate has NOT been guessed in here
# rather than grounded in a real capture, per this project's own
# no-guessing-at-pixel-values discipline.
#
# This is safe to leave as a known gap rather than a live hazard: if this
# coordinate goes stale, _open_package_tab's own status-probe check reads
# "unknown" (neither the available-navy nor claimed-red color, since it'd
# be looking at the wrong tab's content) and run() aborts cleanly via
# _close_gem_shop's verified close WITHOUT ever clicking a purchase button
# on whatever tab it actually landed on -- no currency-risk path exists
# through this failure mode. Re-calibrate against a real capture once the
# tab count actually changes (or the countdown resets on a new campaign
# and this account still shows 3 tabs, meaning it wasn't actually an issue).
GEM_SHOP_PACKAGE_TAB = (1345, 330)
# 毎日無料パッケージ card's own 購入 (purchase) button -- always the
# leftmost card, top row, of the package tab (fixed position, matching the
# reference's own fixed (385, 479)-style offset rather than a search).
GEM_SHOP_FREE_CARD_BUY_BUTTON = (573, 795)
# 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 --
# confirmed live by claiming the real package and diffing before/after
# captures pixel-for-pixel at this exact point:
# available ("一日に1回まで購入可能"): flat dark navy ~(41, 65, 90)
# claimed ("一日に0回まで購入可能"): flat dark red ~(144, 38, 47)
# r vs b relationship alone cleanly separates them (navy: b>r by ~50; red:
# r>b by ~100), so no OCR of the "0"/"1" 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 = (420, 710)
GEM_SHOP_STATUS_AVAILABLE_RGB = (41, 65, 90)
GEM_SHOP_STATUS_CLAIMED_RGB = (144, 38, 47)
GEM_SHOP_STATUS_TOLERANCE = 20
# Generic "is the 青輝石購入 dialog (or a notice/confirm card nested on top
# of it) currently showing" check -- navigation.is_on_subscreen/is_modal_open
# both proved unreliable here, confirmed live: this dialog overlays directly
# on the home screen (keeping its own header bar visible, unlike mailbox/
# cafe/shop's own full subscreen headers) so is_on_subscreen's header probe
# stays dark in both states; is_modal_open's (960, 200) probe lands on the
# dialog's own opaque white card interior rather than a dimmed backdrop, so
# it reads bright (not-dark) whether the dialog is open or not -- the exact
# same class of default-probe mismatch story_sweep.py/bounty.py already
# document for their own wide modals.
#
# These 4 points sit in the dialog card's plain gutter background (confirmed
# identical whichever of the 3 tabs is active, and also identical whether
# the base dialog or a nested confirm/notice card is showing on top) and
# read a tight, near-white, near-equal-RGB cluster there in every dialog
# state captured; the true home screen's own character art was confirmed
# NOT to coincidentally match this closely at ALL 4 spread-out points
# simultaneously (same multi-point-beats-single-point reasoning as
# navigation.is_header_bar_visible, added the same day for an unrelated
# cafe bug -- reused here independently since this dialog's false-positive
# risk is a different shape, not the header row).
GEM_SHOP_DIALOG_PROBES = ((960, 200), (960, 250), (1500, 600), (420, 600))
GEM_SHOP_DIALOG_PROBE_MIN_CHANNEL = 220
GEM_SHOP_DIALOG_PROBE_MAX_SPREAD = 15
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 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.
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).
LOGIN_HOME_NAV_BAR_PROBES = ((430, 1150), (720, 1150), (940, 1150), (1230, 1150), (1430, 1150))
LOGIN_HOME_NAV_BAR_MIN_CHANNEL = 220
LOGIN_HOME_NAV_BAR_MAX_SPREAD = 25
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