"""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. - Originally (2026-07-09) this fought 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 via `self.next_time = 55`, which this project's one-shot CLI has no equivalent for). Per explicit user direction (2026-07-12), this now loops internally instead: `_fight_one` fights a single battle, and `run()` calls it repeatedly until the OCR'd ticket count reaches 0 (or config.ARENA_MAX_FIGHTS_PER_RUN, a defensive bound only -- not a hardcoded assumption of the account's real daily ticket count, which is read live every run like everything else in this project), waiting config.ARENA_POST_BATTLE_COOLDOWN (30s, per the user) between fights for a real in-game lockout after a battle finishes before the next one can be queued. `config.ARENA_STOP_FIGHT_WHEN_RANK1` is now re-checked before every fight in the loop, not just once before the first -- rank can change mid-run from fighting. Reward collection still runs unconditionally once at the end regardless of how the loop exits (ticket exhaustion, a rank-1 stop, or a fight that didn't complete cleanly) rather than the reference's "only if this was the last ticket" rule -- both reward slots are idempotent/harmless to check regardless. 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 _fight_one(driver, config): """Fight exactly one battle: reroll to an acceptable opponent, commit to attack formation (spends a ticket), sortie, and wait out the result. Returns True once through to the result being dismissed, False if any step along the way couldn't be confirmed -- callers should stop looping on False rather than guess whether it's safe to try again immediately. """ 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 False 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 False _ensure_skip_on(driver, config) print("[arena] sortie") driver.keypress(config.ARENA_SORTIE_CONFIRM_KEY) driver.wait(2) _wait_for_result(driver, config) 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}") fights = 0 while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN: if config.ARENA_STOP_FIGHT_WHEN_RANK1: rank = _read_rank(driver, config) if rank == 1: print("[arena] already rank 1 -- not fighting") break print(f"[arena] current rank: {rank}") if not _fight_one(driver, config): print("[arena] fight did not complete cleanly -- stopping without attempting further fights") break fights += 1 new_tickets = _read_ticket_count(driver, config) if new_tickets is None: print("[arena] could not re-read ticket count after the fight -- stopping rather than guess whether more remain") break tickets = new_tickets print(f"[arena] tickets remaining: {tickets}") if tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN: print(f"[arena] waiting {config.ARENA_POST_BATTLE_COOLDOWN}s for the post-battle cooldown before the next fight") driver.wait(config.ARENA_POST_BATTLE_COOLDOWN) print(f"[arena] fought {fights} battle(s) this run") _collect_rewards(driver, config) print("[arena] Done.")