- 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.
1303 lines
76 KiB
Python
1303 lines
76 KiB
Python
"""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
|