745 lines
43 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)
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 --
# confirmed against screenshots/cafe/student/01-02: navigation.is_on_subscreen's
# header probe reads (183,220,240) there (r<200, fails) vs (248,249,250) on
# the normal cafe screen (r>200, passes), so the existing header-brightness
# check already tells the two apart. Bounds how many Enter presses
# _dismiss_rank_up_if_shown will try before giving up.
CAFE_RANK_UP_DISMISS_RETRIES = 5
# 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_ICON = (1787, 300)
# The badge carousel's own small pagination dots, directly below the
# thumbnail -- confirmed LIVE CLICKABLE and immediately switch which item
# the badge shows, rather than waiting for the carousel's own auto-rotate
# timer (observed to be slow enough that a fixed page could still be
# showing 20-30+ seconds later, well past this task's retry window). Pixel-
# scanned live: with 2 known carousel items, the dots sit ~14px apart
# starting at x=1780; clicking the leftmost one deterministically selected
# the current running event over an old event's leftover reward-claim
# reminder. event_sweep.py clicks a specific dot BEFORE each badge-open
# attempt (cycling through positions across its outer retry loop) instead
# of repeatedly clicking the ambiguous badge and hoping the timer has
# moved on. Clicking a dot position that doesn't exist (beyond however many
# items are actually queued) is a harmless no-op click on plain background.
EVENT_BADGE_DOT_Y = 400
EVENT_BADGE_DOT_X = (1780, 1794, 1808)
# 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.
EVENT_SWEEP_RESULT_BUTTON_REGION = (1000, 700, 1300, 1050)
# 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 = (85, 55)
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 = (85, 55) # shared by both the region-list and per-region map screens
# 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
# 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)