"""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. Consecutive-invocation navigation fix (2026-07-11): this task never returned home at the end, unlike lesson.py/shop_*.py/event_sweep.py. Live bug report: a first invocation completed normally and left the game sitting on the Tactical Challenge screen; a second invocation's _open_tactical_challenge then failed all 3 retries, because its WORK_ICON/ARENA_WORK_HUB_CARD clicks are home-screen-relative coordinates that mean nothing from wherever the previous run left the game. Fixed by calling the shared navigation.return_to_home(driver) at the very start of run(), before _open_tactical_challenge -- the same generic Escape-based recovery primitive event_sweep.py uses for its own wrong-page recovery. """ 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() # This task never returns home at the end (unlike lesson.py/shop_*.py/ # event_sweep.py) -- confirmed live: a second invocation starting from # wherever the previous run left the game (the arena list, a leftover # modal, etc.) sent _open_tactical_challenge's WORK_ICON/ARENA_WORK_HUB_ # CARD clicks to the wrong place, since those coordinates only mean # anything from the home screen, and it failed to reopen tactical # challenge at all. Reset to a known state first via the shared # navigation.return_to_home primitive (built for exactly this purpose # during event_sweep.py's own navigation debugging) -- Escape-based, # verifies before every press, safe across this project's screens. if not navigation.return_to_home(driver): print("[arena] warning: could not confirm return to home screen -- attempting to open tactical challenge anyway") 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.")