- 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.
304 lines
13 KiB
Python
304 lines
13 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.
|
|
"""
|
|
|
|
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.")
|