Nik Afiq 634d77d9d2 feat(clean_scratchpad): implement script for safe deletion of temporary files in scratchpad directory
fix(arena): resolve consecutive-invocation navigation issue by ensuring return to home before opening Tactical Challenge
docs(mapping): update Arena task description with navigation bug fix details
2026-07-11 00:20:04 +09:00

328 lines
15 KiB
Python

"""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.")