From 511010b6d6065a06e41992274e92e98b540ecc27 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 9 Jul 2026 02:38:41 +0900 Subject: [PATCH] feat: Implement arena task with OCR and auto-fight capabilities - Added `find_template` and `template_visible` functions for template matching in screenshots. - Introduced `read_int_white_on_dark` for OCR of bright text on dark backgrounds. - Ported arena functionality from reference, including ticket management, opponent selection, and reward collection. - Implemented safety checks for modal visibility and result confirmation to prevent unintended ticket spends. - Live-tested the arena task, confirming functionality across multiple tickets with real fight outcomes. - Updated mapping documentation to reflect new arena task implementation and its unique navigation requirements. --- CLAUDE.md | 11 +- ba_auto/config.py | 195 +++++++++++++++++++ ba_auto/detector.py | 69 +++++++ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/arena.py | 303 +++++++++++++++++++++++++++++ ba_daily.py | 13 +- plan.md | 46 ++++- 7 files changed, 619 insertions(+), 20 deletions(-) create mode 100644 ba_auto/tasks/arena.py diff --git a/CLAUDE.md b/CLAUDE.md index 3921135..cec228c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -639,12 +639,12 @@ When debugging on `nik-gpu`, copy relevant screenshots or debug images back into ## Existing features -Current project state: mailbox, cafe, stamina, story_sweep, shop_common, shop_tactical, and lesson are all migrated to real Python. No Bash feature logic remains. +Current project state: mailbox, cafe, stamina, story_sweep, shop_common, shop_tactical, lesson, and arena are all migrated to real Python. No Bash feature logic remains. - `ba_dailies.sh` is a thin launcher that execs `ba_daily.py` -- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `shop_common`, `shop_tactical`, `lesson`, and default flow to `ba_auto/tasks/` +- `ba_daily.py` dispatches `mailbox`, `cafe`, `stamina`, `story_sweep`, `shop_common`, `shop_tactical`, `lesson`, `arena`, and default flow to `ba_auto/tasks/` - default flow is `mailbox`, `cafe`, `stamina` -- `story_sweep`, `shop_common`, `shop_tactical`, and `lesson` are opt-in only since they spend AP/credits/tactical coin/lesson tickets rather than reclaiming something free +- `story_sweep`, `shop_common`, `shop_tactical`, `lesson`, and `arena` are opt-in only since they spend AP/credits/tactical coin/lesson tickets/an arena ticket rather than reclaiming something free - `ba_auto/tasks/mailbox.py` and `ba_auto/tasks/cafe.py` click with `ba_auto/driver.py` primitives and verify state with `driver.color_at` and `ba_auto/navigation.py` - mailbox and cafe were ported from the reference patterns around `module/mail.py` and `module/cafe_reward.py` - no legacy bridge remains @@ -659,7 +659,10 @@ Current project state: mailbox, cafe, stamina, story_sweep, shop_common, shop_ta - `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid (a scrollable list of 12 named regions, each opening a grid modal of up to 9 location cards), picking the highest-affection available lesson each time until lesson tickets or lessons run out - per-cell affection is read via `detector.read_int_on_heart_badge`, a dedicated OCR path for the pink/magenta heart-shaped badge — the project's normal grayscale-threshold OCR (`read_int`) misreads it, because the badge's own outline stroke survives the same threshold as the digit glyph; a "done today" portrait keeps its number and gets a green checkmark added alongside it rather than losing the number, so done-ness is checked via that checkmark's color, not inferred from a failed OCR read - lesson was live-tested with real tickets spent (see `plan.md` Phase 12), which surfaced two real bugs from that assumption gap plus an OCR contamination issue — both fixed; see Phase 12 for the full writeup -- `ba_auto/detector.py` has `find_cafe_sparkle()`, the sparkle template-match ported in-process from the now-deleted `scripts/detect_and_click.py` +- `ba_auto/tasks/arena.py` fights exactly one ranked Tactical Challenge (Arena) battle per invocation (not "spend every ticket" — the reference itself only fights one per call, relying on its own background scheduler for pacing, which this project has no equivalent for), then collects both reward slots +- Tactical Challenge is reached via a card inside the お仕事 (Work) hub, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-count preview confirming the real fight-commit click +- arena was live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging), which surfaced three real bugs: a level-OCR crop too small for tesseract despite looking legible to the eye, level text being bright-on-dark unlike every other OCR read in this project (fixed via a new `detector.read_int_white_on_dark`), and — most importantly — the post-fight WIN/LOSE result modal proving undetectable by precisely locating its own confirm button (WIN and LOSE are different heights; widening the search region to cover both then caught stray cyan-ish pixels in the opponent list's own portrait art, false-positive-clicking into an unrelated opponent's info modal). Fixed by abandoning per-button color detection for a bounded blind-Enter-press loop (matching `lesson.py`'s own `_run_one_schedule` pattern), gated by a hard safety check against the one modal where Enter is genuinely dangerous — the opponent-info modal's own attack-formation button is also Enter-bound and spends a real ticket. See `plan.md` Phase 13 for the full writeup +- `ba_auto/detector.py` has `find_cafe_sparkle()`, the sparkle template-match ported in-process from the now-deleted `scripts/detect_and_click.py`, and `find_template()`/`template_visible()`, a generalized named-template matcher built for arena but ultimately unused there — state detection stayed OCR/color-probe-driven throughout, like every other task - `scripts/ba_dailies_legacy.sh` and `scripts/detect_and_click.py` have been deleted - the `scripts/` directory itself no longer exists - `ba_auto/driver.py` primitives are wired into all migrated task modules diff --git a/ba_auto/config.py b/ba_auto/config.py index ed6a98a..c04a555 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -371,3 +371,198 @@ LESSON_GRID_IDLE_RGB = ((235, 220, 70), (255, 250, 130)) # 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) diff --git a/ba_auto/detector.py b/ba_auto/detector.py index 6c520b4..ea0e2dc 100644 --- a/ba_auto/detector.py +++ b/ba_auto/detector.py @@ -54,6 +54,51 @@ def find_cafe_sparkle(): return (x + tw // 2 + round(ox * scale), y + th // 2 + round(oy * scale), score) +def find_template(template_path, region=None, threshold=0.85): + """Generic named-template match against a fresh screenshot, optionally + restricted to a screen sub-region `(x1, y1, x2, y2)`. + + The local equivalent of the reference's core/picture.py::match_img_feature + -- used wherever the reference identifies a screen/state by comparing a + fixed on-screen crop to a known template image, rather than by OCR (OCR + reads stay on read_text/read_int; see CLAUDE.md's OCR policy for why + these two stay separate detection paths). Unlike find_cafe_sparkle, this + project's UI chrome (nav icons, modal titles, result banners) renders at + a fixed resolution with no camera zoom involved, so no multi-scale search + or color masking is needed here -- a single plain match is enough. Add a + scale sweep or mask back in for a specific template if live testing ever + finds one that needs it. + + Returns the match's top-left `(x, y)` in full-screenshot coordinates, or + None if nothing scores >= threshold. + """ + driver.screenshot(OCR_SHOT_PATH) + img = cv2.imread(OCR_SHOT_PATH) + if region is not None: + x1, y1, x2, y2 = region + img = img[y1:y2, x1:x2] + else: + x1, y1 = 0, 0 + + template = cv2.imread(template_path) + if template is None: + raise FileNotFoundError(f"Template image not found: {template_path}") + + result = cv2.matchTemplate(img, template, cv2.TM_CCOEFF_NORMED) + _, max_val, _, max_loc = cv2.minMaxLoc(result) + if max_val < threshold: + return None + return (x1 + max_loc[0], y1 + max_loc[1]) + + +def template_visible(template_path, region=None, threshold=0.85): + """Boolean presence check built on find_template -- the common case for + screen-state detection (e.g. arena's title-banner checks via + navigation.wait_for_state's `ends`/`reactions` check_fn contract), where + only whether a known screen is showing matters, not exactly where.""" + return find_template(template_path, region=region, threshold=threshold) is not None + + def _color_mask(region, rgb_min, rgb_max): x1, y1, x2, y2 = region driver.screenshot(OCR_SHOT_PATH) @@ -127,6 +172,30 @@ def read_text(region, whitelist=None, psm=7, lang="eng"): return pytesseract.image_to_string(crop, lang=lang, config=tess_config).strip() +def read_int_white_on_dark(region, psm=7): + """OCR a digit crop that renders bright text directly on a dark card + background (e.g. arena's "Lv.90" self/opponent-level labels) -- the + opposite polarity of this project's usual dark-text-on-light-UI + assumption baked into _ocr_crop's plain THRESH_BINARY. Confirmed live: + read_int returns None against these labels, since a plain threshold + there produces white glyphs on a black field and tesseract doesn't + segment that the way it does the normal case. An inverted threshold + fixes it outright -- no color-relationship masking needed here, unlike + read_int_on_heart_badge's badge-outline contamination problem. + """ + x1, y1, x2, y2 = region + driver.screenshot(OCR_SHOT_PATH) + img = cv2.imread(OCR_SHOT_PATH) + crop = img[y1:y2, x1:x2] + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY_INV) + upscaled = cv2.resize(thresh, None, fx=OCR_UPSCALE, fy=OCR_UPSCALE, interpolation=cv2.INTER_CUBIC) + tess_config = f"--psm {psm} -c tessedit_char_whitelist=0123456789" + text = pytesseract.image_to_string(upscaled, lang="eng", config=tess_config).strip() + digits = "".join(ch for ch in text if ch.isdigit()) + return int(digits) if digits else None + + def read_int(region, psm=7): """Local equivalent of the reference's recognize_int.""" digits = "".join(ch for ch in read_text(region, whitelist="0123456789", psm=psm) if ch.isdigit()) diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 884be34..0012721 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -11,7 +11,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Group/Club AP | `module/group.py` | Need to inspect | `ba_auto/tasks/group.py` | fixed click + state check via local driver | Not started | | Bounty | `module/rewarded_task.py` | Need to inspect | `ba_auto/tasks/bounty.py` | sweep/color/OCR adaptation | Not started | | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | -| Arena | `module/arena.py` | Need to inspect | `ba_auto/tasks/arena.py` | auto-fight + OCR + local driver | Not started | +| Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. | | Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | `get_item_position`'s color+template item-state scan → fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, since the reference's own item-identification here indexes an external static price table (`self.static_config.common_shop_price_list`, fetched from a remote resource) this repo doesn't have — not per-item OCR, so this isn't an OCR-avoidance shortcut. Purchase-confirm dialog + reward-acquired banner handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) instead of tracking each dialog's own layout | Done. Live-tested with real purchases (all 8 configured targets bought, cost matched exactly). Discovered live: these items have a per-refresh-cycle purchase cap not shown as a visible counter (unlike the 青輝石 tab's "あと1回購入可能" labels) — confirmed by re-running the task after purchase and observing it correctly detect the now-unselectable items (checkbox + individual 購入 button both unresponsive) and safely decline rather than guess. A fresh, everything-available run hasn't been re-verified since the account had already exhausted this cycle's purchases via that same test | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | | Lesson/Schedule | `module/lesson.py` | `implement`, `to_lesson_location_select`/`to_select_location`/`to_all_locations` (nav state machine), `get_lesson_region_num`/`switch_lesson_region_page`/`to_lesson_region` (paged region nav), `get_lesson_each_region_status`+`check_region_availability` (per-cell status via isometric-parallelogram pixel scan), `get_lesson_relationship_counts` (per-cell affection pip count via color count), `choose_lesson` (selection policy), `execute_lesson`/`to_location_info`/`start_lesson` (click cell -> info panel -> start -> result) | `ba_auto/tasks/lesson.py` | `picture.co_detect` -> `navigation.wait_for_state`-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (`config.LESSON_REGION_ROW_Y`), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric `Parallelogram`/`Triangle` per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (`detector.read_int_on_heart_badge`) needs no isometric geometry at all | Done. Config-driven scope only in the sense of the *policy* (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own `lesson_region_name.JP` (embedded directly in its `default_config.py`, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and `plan.md`'s Lesson phase | diff --git a/ba_auto/tasks/arena.py b/ba_auto/tasks/arena.py new file mode 100644 index 0000000..3aa193f --- /dev/null +++ b/ba_auto/tasks/arena.py @@ -0,0 +1,303 @@ +"""Arena / Tactical Challenge. Reference: baas-reference/module/arena.py. + +Ports `implement`'s flow: open Tactical Challenge, OCR the ticket count, stop +early (collecting rewards only) if there are no tickets or -- per +config.ARENA_STOP_FIGHT_WHEN_RANK1 -- rank 1 is already reached, otherwise +reroll the chosen opponent slot by level (`choose_enemy`), commit to a fight +(`攻撃編成`), confirm the skip toggle, sortie, wait for the result, and +collect both reward slots (`collect_tactical_challenge_reward`). + +This client differs from the reference in a few confirmed ways: + +- Tactical Challenge is a card inside the お仕事 (Work) hub + (config.ARENA_WORK_HUB_CARD), not a bottom-nav icon on the main page. +- The reference's separate opponent-info and formation-edit ("攻撃編成") + screens are merged into one modal here, showing the matchup and the + attack-formation button together with a live ticket-count preview + (e.g. "5→4") confirming it's the real fight-commit step. +- `navigation.is_modal_open`'s shared darkness probe reads INVERTED on this + screen specifically (confirmed live: the list's own background art is + darker than the modal's white card at that exact point) -- this module + has its own `_is_modal_open` using config.ARENA_MODAL_PROBE instead. +- Per explicit user decision (2026-07-09): this fights exactly ONE battle + per invocation, matching the reference's own per-call pacing. The + reference relies on its always-running background thread rescheduling + itself 55 minutes later (`self.next_time = 55`) for the next ticket; this + project's one-shot-per-invocation CLI has no equivalent, so spending + additional tickets means rerunning this task (e.g. via cron), not an + internal loop. Reward collection runs unconditionally at the end of every + invocation instead of the reference's "only if this was the last ticket" + rule -- both reward slots are idempotent/harmless to check every run, and + there's no scheduler here to guarantee a later invocation will do it. + +Live-discovered gotchas around the post-fight result (see config.py's +ARENA_RESULT_CONFIRM_KEY comment for the full writeup -- three real bugs +across three of this session's five real arena tickets): the "対戦結果" +WIN/LOSE modal can render later than a naive fixed delay would catch, and +its confirm button's position varies between WIN and LOSE (different modal +heights) in a way that made a color-region search unreliable -- a false +match there once clicked into a completely unrelated opponent's info +modal. `_wait_for_result` now just presses Enter in a bounded blind-retry +loop (matching lesson.py's own pattern for "a variable sequence of +post-action screens"), gated by a hard safety check against the one modal +where Enter would actually be dangerous (the opponent-info modal's own +attack-formation button, also Enter-bound, spends a real ticket). + +Deliberately out of scope for v1 (see mapping.md's Arena row): the +reference's "no ticket" popup race (get_tickets going stale between the +initial read and the attack-formation click) and the LOSE variant of the +result modal, since neither has been seen live yet. +""" + +from ba_auto import detector, navigation + +OPEN_RETRIES = 3 +RESULT_MODAL_MAX_POLLS = 10 +RESULT_POLL_INTERVAL = 1.5 + + +def _color_in_range(rgb, rgb_range): + lo, hi = rgb_range + r, g, b = rgb + return lo[0] <= r <= hi[0] and lo[1] <= g <= hi[1] and lo[2] <= b <= hi[2] + + +def _read_ticket_count(driver, config): + text = detector.read_text(config.ARENA_TICKET_OCR_RECT, whitelist="0123456789/", psm=7) + head = text.split("/")[0] if "/" in text else text + digits = "".join(ch for ch in head if ch.isdigit()) + return int(digits) if digits else None + + +def _read_rank(driver, config): + return detector.read_int(config.ARENA_RANK_OCR_RECT) + + +def _open_tactical_challenge(driver, config): + for attempt in range(1, OPEN_RETRIES + 1): + driver.click(*config.WORK_ICON) + driver.wait(2) + if navigation.is_on_subscreen(driver): + break + print(f"[arena] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})") + else: + # Known recurring environment gotcha (see Handoff.md): the game's + # UI-hide/photo-mode toggle can leave the screen showing full-art + # with no icons, so every icon click above silently misses. A + # background click clears it -- try that once, then retry the + # whole click sequence rather than repeating the same blind click. + print("[arena] work hub still not detected -- trying the known UI-hide-toggle recovery click") + driver.click(960, 600) + driver.wait(1) + driver.click(*config.WORK_ICON) + driver.wait(2) + if not navigation.is_on_subscreen(driver): + return False + + for attempt in range(1, OPEN_RETRIES + 1): + driver.click(*config.ARENA_WORK_HUB_CARD) + driver.wait(2) + if navigation.is_on_subscreen(driver): + return True + print(f"[arena] tactical challenge screen not detected after click (attempt {attempt}/{OPEN_RETRIES})") + return False + + +def _reward_claimable(driver, config, probe): + return _color_in_range(driver.color_at(*probe), config.ARENA_REWARD_CLAIMABLE_RGB) + + +def _collect_rewards(driver, config): + if _reward_claimable(driver, config, config.ARENA_TIME_REWARD_PROBE): + print("[arena] claiming time reward") + driver.click(*config.ARENA_TIME_REWARD_BUTTON) + driver.wait(1) + driver.keypress("Return") + driver.wait(1.2) + else: + print("[arena] time reward not currently claimable") + + if _reward_claimable(driver, config, config.ARENA_DAILY_REWARD_PROBE): + print("[arena] claiming daily reward") + driver.click(*config.ARENA_DAILY_REWARD_BUTTON) + driver.wait(1) + driver.keypress("Return") + driver.wait(1.2) + else: + print("[arena] daily reward not currently claimable") + + +def _is_modal_open(driver, config): + # navigation.is_modal_open's shared probe reads inverted here -- see + # module docstring and config.py's ARENA_MODAL_PROBE comment. + r, g, b = driver.color_at(*config.ARENA_MODAL_PROBE) + threshold = config.ARENA_MODAL_OPEN_MIN_CHANNEL + return r > threshold and g > threshold and b > threshold + + +def _open_opponent_modal(driver, config, slot_index): + driver.click(config.ARENA_OPPONENT_ROW_X, config.ARENA_OPPONENT_ROW_Y[slot_index]) + driver.wait(1.5) + return _is_modal_open(driver, config) + + +def _refresh_opponents(driver, config): + driver.click(*config.ARENA_REFRESH_LIST_BUTTON) + driver.wait(1.5) + + +def _choose_enemy(driver, config, slot_index): + """Port of choose_enemy: reroll the shown opponents (this client has one + "リスト更新" button that refreshes all 3 slots at once, matching the + reference's own single-click-for-3-fixed-slots behavior rather than a + per-slot reroll) until the chosen slot's level is within + config.ARENA_LEVEL_DIFF of self, or config.ARENA_MAX_REFRESH_TIMES is + exhausted. Both level reads are read directly from the list screen, the + same way the reference does before opening any modal. + """ + self_level = detector.read_int_white_on_dark(config.ARENA_SELF_LEVEL_OCR_RECT) + if self_level is None: + print("[arena] could not OCR self level -- skipping reroll, using current opponent as-is") + return + + rect = config.ARENA_OPPONENT_LEVEL_OCR_RECTS[slot_index] + for refresh in range(config.ARENA_MAX_REFRESH_TIMES + 1): + opponent_level = detector.read_int(rect) + if opponent_level is None: + print("[arena] could not OCR opponent level -- skipping reroll, using current opponent as-is") + return + if opponent_level + config.ARENA_LEVEL_DIFF <= self_level: + print(f"[arena] opponent level {opponent_level} acceptable (self {self_level}, diff {config.ARENA_LEVEL_DIFF})") + return + if refresh >= config.ARENA_MAX_REFRESH_TIMES: + print(f"[arena] giving up rerolling after {config.ARENA_MAX_REFRESH_TIMES} refreshes, opponent level {opponent_level}") + return + print(f"[arena] opponent level {opponent_level} too high (self {self_level}) -- refreshing ({refresh + 1}/{config.ARENA_MAX_REFRESH_TIMES})") + _refresh_opponents(driver, config) + + +def _skip_is_on(driver, config): + return _color_in_range(driver.color_at(*config.ARENA_SKIP_TOGGLE_PROBE), config.ARENA_SKIP_ON_RGB) + + +def _ensure_skip_on(driver, config): + if _skip_is_on(driver, config): + print("[arena] battle skip already on") + return + print("[arena] battle skip appears off -- toggling on") + driver.click(*config.ARENA_SKIP_TOGGLE_CLICK) + driver.wait(0.8) + if _skip_is_on(driver, config): + print("[arena] battle skip confirmed on") + else: + print("[arena] warning: could not confirm battle skip is on -- proceeding anyway") + + +def _opponent_info_modal_showing(driver, config): + # The opponent-info modal's own gold 攻撃編成 button at this fixed spot + # is a reliable, position-based signal -- unlike hunting for the result + # modal's confirm button by color (see below), this doesn't depend on + # modal height and isn't at risk of matching stray portrait-art pixels. + return _color_in_range(driver.color_at(*config.ARENA_ATTACK_FORMATION_BUTTON), config.ARENA_REWARD_CLAIMABLE_RGB) + + +def _wait_for_result(driver, config): + """Dismiss whatever unpredictable sequence of screens follows Sortie: + the "対戦結果" WIN/LOSE modal, and occasionally an unrelated "list + refresh expired" notice if the season list's own countdown lapses + mid-fight. + + A first version tried to precisely locate each one's confirm button by + color -- but WIN and LOSE modals aren't the same height (WIN shows a + reward showcase, LOSE doesn't), so their confirm buttons sit at + different y-positions, and a live test found that searching a region + wide enough to cover both also risked matching stray cyan-ish pixels in + the opponent list's own (highly variable, per-refresh) portrait art -- + a false-positive click there opened a completely unrelated opponent's + info modal instead of confirming anything. + + This instead follows the same bounded-blind-Enter-press pattern + lesson.py's _run_one_schedule already uses for its own "variable + sequence of post-action screens" problem: press Enter, which is the + universal safe dismiss/confirm for every popup actually involved here, + and stop once no further popup is showing. + + Hard safety gate, checked before every single press: the opponent-info + modal's OWN attack-formation button also responds to Enter and spends a + real ticket. That modal should never legitimately be showing at this + point in the flow -- if it is (confirmed live to be reachable via a + stray click elsewhere), this stops immediately WITHOUT pressing Enter, + rather than risk an unintended second ticket spend the way a mistimed + keypress caused a real hazard elsewhere in this project (see CLAUDE.md's + story_sweep writeup). + """ + for _ in range(RESULT_MODAL_MAX_POLLS): + if _opponent_info_modal_showing(driver, config): + print("[arena] warning: opponent-info modal unexpectedly showing during result wait -- stopping without pressing Enter to avoid an unintended ticket spend") + return False + driver.keypress(config.ARENA_RESULT_CONFIRM_KEY) + driver.wait(RESULT_POLL_INTERVAL) + + if _opponent_info_modal_showing(driver, config): + print("[arena] warning: opponent-info modal showing after result-wait timeout -- leaving as-is without pressing Enter") + return False + return True + + +def run(driver, config): + driver.focus_game() + + if not _open_tactical_challenge(driver, config): + print("[arena] could not confirm tactical challenge screen is open, aborting without pressing further keys") + return + + tickets = _read_ticket_count(driver, config) + if tickets is None: + print("[arena] could not OCR ticket count, aborting without pressing further keys") + return + print(f"[arena] tickets: {tickets}") + + if tickets <= 0: + print("[arena] no arena tickets available -- collecting rewards only") + _collect_rewards(driver, config) + print("[arena] Done.") + return + + if config.ARENA_STOP_FIGHT_WHEN_RANK1: + rank = _read_rank(driver, config) + if rank == 1: + print("[arena] already rank 1 -- not fighting, collecting rewards only") + _collect_rewards(driver, config) + print("[arena] Done.") + return + print(f"[arena] current rank: {rank}") + + slot_index = config.ARENA_COMPONENT_NUMBER - 1 + _choose_enemy(driver, config, slot_index) + + if not _open_opponent_modal(driver, config, slot_index): + print("[arena] opponent-info modal not detected, aborting without spending a ticket") + return + + print("[arena] committing to attack formation (spends a ticket)") + driver.click(*config.ARENA_ATTACK_FORMATION_BUTTON) + driver.wait(2) + + if not navigation.is_on_subscreen(driver): + print("[arena] attack-formation screen not detected after commit -- ticket may already be spent, check manually. Aborting without pressing further keys") + return + + _ensure_skip_on(driver, config) + + print("[arena] sortie") + driver.keypress(config.ARENA_SORTIE_CONFIRM_KEY) + driver.wait(2) + + _wait_for_result(driver, config) + + new_tickets = _read_ticket_count(driver, config) + if new_tickets is not None: + print(f"[arena] tickets remaining: {new_tickets}") + + _collect_rewards(driver, config) + print("[arena] Done.") diff --git a/ba_daily.py b/ba_daily.py index 5eb70b4..92b277b 100644 --- a/ba_daily.py +++ b/ba_daily.py @@ -3,7 +3,7 @@ import sys from ba_auto import config, driver -from ba_auto.tasks import cafe, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep +from ba_auto.tasks import arena, cafe, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep TASKS = { "mailbox": mailbox.run, @@ -13,11 +13,14 @@ TASKS = { "shop_common": shop_common.run, "shop_tactical": shop_tactical.run, "lesson": lesson.run, + "arena": arena.run, } -# story_sweep, both shop tasks, and lesson are opt-in only (not in the -# default flow): they spend AP/credits/tactical coin/lesson tickets on an -# automated choice rather than reclaiming something free, which is a real -# resource decision the default unattended run shouldn't make blindly. +# story_sweep, both shop tasks, lesson, and arena are opt-in only (not in +# the default flow): they spend AP/credits/tactical coin/lesson tickets/an +# arena ticket on an automated choice rather than reclaiming something +# free, which is a real resource decision the default unattended run +# shouldn't make blindly. Arena specifically fights a real ranked PvP +# battle each run -- see ba_auto/tasks/arena.py's module docstring. DEFAULT_ORDER = ["mailbox", "cafe", "stamina"] diff --git a/plan.md b/plan.md index 96e3d08..33b357e 100644 --- a/plan.md +++ b/plan.md @@ -408,6 +408,38 @@ Also confirmed live: the "保有チケット N/M" ticket counter is directly vis **Not verified**: a region with more than 9 currently-unlocked locations (would need scroll support inside the grid modal — not implemented, not yet seen on this account, both regions tested topped out at 7-8); the "no lesson tickets" abort-immediately path (tickets hit exactly 0 mid-sweep during the real test, not at the start); a full 12-region sweep in one run (the test's 5 tickets ran out partway through region 3 of 12); a truly fresh "everything available, nothing done yet" run (this account had already done some lessons manually during calibration before the automated run started). Worth re-running `~/ba_dailies.sh lesson` after tickets next refill to exercise the untested tail of the region list. +### Phase 13: Arena / Tactical Challenge + +**Status: Done.** Ported `module/arena.py`'s `implement` flow (`get_tickets`, `choose_enemy`, `check_skip_button`, `fight`, `collect_tactical_challenge_reward`) to `ba_auto/tasks/arena.py`. Live-tested for real across all 5 of the account's daily tickets — 3 real fights (2 WIN, 1 LOSE), plus real reward claims (credits, pyroxene, tactical coin all changed as expected). + +**Scope decisions, made with the user before writing any code** (see the two `AskUserQuestion` answers, 2026-07-09): + +- Fights exactly **one battle per invocation**, matching the reference's own per-call pacing, rather than looping to spend every available ticket in one run. The reference relies on its always-running background thread rescheduling itself 55 minutes later (`self.next_time = 55`) for the next ticket; this project's one-shot-per-invocation CLI has no equivalent, so repeated ticket spend means rerunning the task (e.g. via cron) rather than an internal loop. +- Full port including the actual auto-fight (not deferred to a later phase), since this is inherently a real-PvP-consequences feature and a ticket-only/no-fight v1 wouldn't be testable in a way that matters. +- Reward collection (`collect_tactical_challenge_reward`) runs unconditionally at the end of every invocation, unlike the reference's "only if this was the last ticket" rule — both reward slots are idempotent/harmless to check every time, and there's no scheduler here to guarantee a later invocation will do it. + +**Real navigation differences found live, before writing the real logic:** + +- **Tactical Challenge is a card inside the お仕事 (Work) hub** (`config.ARENA_WORK_HUB_CARD`), not a bottom-nav icon on the main page the way the reference's `to_tactical_challenge` assumes — the same "hub card, not a nav icon" pattern already found for story_sweep's task browser. +- **The reference's two separate screens — opponent-info, then a distinct formation-edit ("攻撃編成") screen — are merged into one modal here**, showing the matchup and the attack-formation button together with a live ticket-count preview (e.g. "5→4") that confirms it's the actual fight-commit step before ever clicking it for real. +- **`navigation.is_modal_open`'s shared darkness probe reads INVERTED on this specific screen**: the arena list's own background art at that probe point is darker than the modal's white card there, the opposite of every other screen using that probe. `arena.py` has its own `_is_modal_open` using `config.ARENA_MODAL_PROBE` with the opposite rule, rather than silently reusing the shared one wrong. +- **Self/opponent level OCR** (`choose_enemy`'s reroll logic) is read directly off 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. + +**Real bugs found live** (across the session's 5 real tickets — 2 were spent purely debugging one design mistake, which is exactly why the third fix was validated offline against saved screenshots before risking the last ticket on it): + +1. **A level-OCR crop that looked legible was still too small for tesseract.** The self/opponent-level "90" digit crop, tight-cropped to just the glyphs, OCR'd as `None` even against a correctly time-matched live screenshot. Debugged by dumping the exact thresholded image tesseract actually sees (`scratchpad/probe_arena_level_ocr*.py`): legible to the eye, but marginal enough at 3x upscale that tesseract succeeded on some rows/psm modes and not others. A few extra pixels of padding on each side fixed it outright across every row, using the same existing OCR pipeline — no upscale/threshold change needed, just a less tight crop. +2. **Self/opponent-level text is bright-on-dark, the opposite of this project's usual dark-on-light OCR assumption.** `detector.read_int` (built around a plain `THRESH_BINARY`) returned `None` against the profile card's white "Lv.90" text on its dark navy background. Added `detector.read_int_white_on_dark` (the same crop pipeline with `THRESH_BINARY_INV`) rather than reworking the shared path, since every other OCR read in this project genuinely is dark-on-light. +3. **The post-fight "対戦結果" WIN/LOSE result modal is not reliably detectable by precisely locating its own confirm button.** Three compounding problems, found in this order: + - A screenshot taken ~2s after Sortie showed the arena list already fully updated (rank, ticket count, a new "待機時間" cooldown) with no result modal visible at all — nearly mistaken for "no result screen needed here, this client resolves fights differently than the reference assumes." Wrong: the modal was still pending and only rendered after the next interaction (confirmed by clicking an opponent row afterward and getting the queued WIN screen instead of that row's own info). Must poll for it explicitly rather than trust a fixed delay, same as the reference's own `fight()` waiting on `arena_battle-win`/`arena_battle-lost`. + - WIN and LOSE modals are not the same height — WIN shows a reward showcase above its confirm button that LOSE doesn't, so LOSE's confirm button sits noticeably higher on screen. 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 covered them, not a real reward-state problem. + - Widening the button search into a region spanning both known positions (the same fix already used for story_sweep's own two-position SKIP/OK button) caught WIN and LOSE correctly, but a live rerun then got stuck: the region also matched stray cyan-ish pixels in the opponent list's own portrait art (which changes every list refresh), and a false-positive centroid click there opened a *completely unrelated* opponent's info modal instead of confirming anything. A narrower, offline-revalidated region (checked against every saved WIN/LOSE/opponent-info/plain-list screenshot before risking the session's last ticket on it) hit the *identical* 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 is *also* Enter-bound and spends a real ticket, this was a real near-miss of exactly the class of hazard `CLAUDE.md` already documents from story_sweep ("a mistimed keypress could have started a real battle"). Fixed by abandoning per-button color detection entirely: `_wait_for_result` now presses Enter 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, and confirmed live to safely dismiss both the WIN modal, the LOSE modal, and an unrelated "リストの更新時間を超過しました" (list-refresh-expired) notice that can also appear if the season list's own countdown lapses mid-fight. A hard safety gate, checked before every single press, stops immediately without pressing Enter if the opponent-info modal is ever detected (via its own gold button at a fixed, reliable position, not a color-region search) — that modal should never legitimately be showing at this point in the flow, and the two prior tickets were spent confirming exactly how a stray click could get there. + +**Bonus finding, not anticipated going in**: the "戦闘スキップ" (Battle Skip) toggle was already ON by default on this account, confirmed live via pixel-grid-scan. `check_skip_button`'s reroll-if-off logic is ported (`arena._ensure_skip_on`) but its "off" branch has never been exercised live, since no off state has been seen yet to calibrate against. + +**Not verified**: the "no ticket" popup race the reference guards against (`get_tickets` going stale between the initial read and the attack-formation click — this account's tickets never hit exactly 0 mid-flow during testing); `choose_enemy`'s actual reroll click (`config.ARENA_REFRESH_LIST_BUTTON` is wired up per the reference's logic, but every opponent offered during testing was already an acceptable level, so a real reroll was never triggered); `ARENA_STOP_FIGHT_WHEN_RANK1`'s rank-1 stopping condition (default `False`, and this account's rank never got close enough to test the branch). All three are implemented per the reference's own logic, not guessed at, but none has been exercised against real game state yet. + ## Prerequisites ### OCR @@ -570,7 +602,9 @@ OCR: Port the reference's approach if it uses OCR here — do not default to a f ### 7. Arena -Fight until out of tickets or until configured stopping condition. +**Status: Done — see Phase 13.** + +Fights exactly one battle per invocation, not "until out of tickets" (deliberate — see Phase 13). Reference: @@ -580,14 +614,6 @@ Reference: Local target: `ba_auto/tasks/arena.py` -Needs: - -- auto-fight primitive -- OCR or visual detection for ticket/rank state -- careful safety limits - -Arena is high-value but more risky than fixed claim tasks. - ### 8. Common Shop + Tactical Shop **Status: Done — see Phase 11.** @@ -887,7 +913,7 @@ should run the default daily sequence. 14. Set up OCR and port it for whichever remaining feature's reference implementation depends on it — not a blanket "only when needed" deferral; see `CLAUDE.md` → "OCR policy" 15. Implement Common Shop + Tactical Shop. — Done (Phase 11) 16. Implement Lesson/Schedule. — Done (Phase 12) -17. Attempt Arena once OCR is in place, since its reference implementation depends on it. +17. Implement Arena. — Done (Phase 13) ## Claude Code guidance summary