Nik Afiq c5873f4e58 Refactor arena and lesson tasks for improved efficiency and reliability
- Updated arena.py to allow multiple battles per invocation, looping until tickets are exhausted or a rank-1 condition is met. Introduced _fight_one function for single battle logic and added cooldown handling between fights.
- Enhanced lesson.py to implement a tiered priority system for scheduling lessons based on student slots available, replacing the previous highest affection value selection. Introduced functions for scanning all regions and building a priority queue for lesson scheduling.
- Centralized return-to-home logic in ba_daily.py to ensure the game returns to the home screen before and after each task, improving robustness against navigation issues.
- Added retry mechanism for returning to home, allowing for transient navigation issues to be handled gracefully without aborting tasks.
2026-07-13 10:32:53 +09:00

353 lines
16 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.
- 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.")