Nik Afiq a30c3888d6 fix: address multiple bugs causing incorrect battle handling and result screen issues
- In arena.py, widened RESULT_MODAL_MAX_POLLS to 200 to accommodate real battle durations and added early exit check in _wait_for_result to prevent stuck screens.
- Updated _fight_one to properly handle _wait_for_result's return value, ensuring failures stop the fight loop.
- In bounty.py, implemented OCR verification in _confirm_dialog_is_sweep to ensure the correct confirmation dialog is displayed before proceeding with sweeps, preventing unintended battles.
- Documented hazards and fixes in plan.md, detailing incidents from 2026-07-17 that led to these changes.
2026-07-18 12:31:42 +09:00

440 lines
22 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.
Real hazard found via a real unattended `daily` cron run (2026-07-17),
reported live by the user: the log showed "battle skip already on" printed
for all 5 fights and a clean "fought 5 battle(s) this run", but the account
was left showing a real "Battle Complete" result screen with a ~3-minute
combat timer -- battles had actually run in full, not skipped -- and every
task that ran for the rest of that `daily` invocation failed to open its
own screen. Two compounding bugs, both fixed: (1) `_ensure_skip_on`'s "on"
detection has never actually been verified to reject a real "off" state
(see config.py's ARENA_SKIP_ON_RGB comment -- skip happened to already be
on every session calibrated so far), so a wrong "already on" reading was
never caught and the toggle was never actually clicked; (2)
independently, and more directly responsible for the stuck screen,
`_wait_for_result` had no early-exit signal at all and always ran its full
fixed budget (10*1.5=15s) before unconditionally declaring success -- far
too short for a real, non-skipped battle, and `_fight_one` on top of that
discarded `_wait_for_result`'s return value entirely, so even a correctly
*detected* failure (including the dangerous opponent-info-modal case its
own hard safety gate exists to catch) never actually stopped `run()`'s
fight loop from proceeding to the next ticket. Fixed by widening
`RESULT_MODAL_MAX_POLLS` substantially (to tolerate a real battle's full
duration regardless of whether the skip-mode detection bug gets fully
root-caused) while adding a new `_back_on_challenge_list` early exit (so
the normal fast-skip-mode case doesn't also slow down), and by making
`_fight_one` actually stop the loop when `_wait_for_result` reports
failure. The skip-toggle's own "off" detection was NOT independently
re-verified (would require spending a real ticket at a moment the account
had none left) -- this fix is deliberately structured to make the
consequences safe even if that underlying detection is still wrong, rather
than depending on fully root-causing it first.
"""
from ba_auto import detector, navigation
OPEN_RETRIES = 3
# Widened from 10 (real hazard found live 2026-07-17, reported by the user):
# this loop originally always ran its full fixed budget with no early exit,
# so at the old 10*1.5=15s bound it silently assumed success even when
# _ensure_skip_on's "already on" check was wrong and a fight actually ran
# as a real, non-skipped battle (confirmed live: a "Battle Complete" result
# screen with a ~3-minute combat timer was found on the account afterward,
# and every task that ran for the rest of that `daily` invocation failed to
# open its own screen). _ensure_skip_on's own "off" detection has never
# actually been verified live (see config.py's ARENA_SKIP_ON_RGB comment --
# it was only ever confirmed to recognize "on", ON by default every session
# so far), so this bound is widened generously to tolerate a real battle
# regardless of whether that gets fixed, rather than trusting skip mode to
# always engage. The new _back_on_challenge_list early-exit (see
# _wait_for_result) means this larger bound doesn't slow down the normal,
# already-working case -- a fast skip-mode fight still exits within the
# first couple of polls, same as before.
RESULT_MODAL_MAX_POLLS = 200
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 _back_on_challenge_list(driver, config):
# The natural "nothing left to dismiss, the fight genuinely wrapped up"
# signal -- the same navigation.is_on_subscreen check
# _open_tactical_challenge itself trusts to confirm this exact screen,
# combined with this module's own (inverted-reading) _is_modal_open to
# rule out a dialog still open on top of it. Added alongside widening
# RESULT_MODAL_MAX_POLLS (2026-07-17) so a real, non-skipped battle's
# much longer wait doesn't also slow down the normal fast-skip-mode
# case -- lets _wait_for_result's loop exit as soon as a fight is
# actually done instead of always blindly running its full budget.
return navigation.is_on_subscreen(driver) and not _is_modal_open(driver, config)
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).
Real hazard found live (2026-07-17, reported by the user): this loop
originally had no early exit at all -- it always ran its full fixed
budget (10*1.5=15s), assuming success once that budget elapsed. When
_ensure_skip_on's "battle skip already on" check was wrong (see
config.py's ARENA_SKIP_ON_RGB comment -- its "off" detection was never
actually verified live), a fight ran as a real, non-skipped battle
(confirmed via a screenshot: a "Battle Complete" result screen with a
~3-minute combat timer), and this loop declared the result handled
after 15s regardless -- while the real battle was still playing. Every
task that ran for the rest of that `daily` invocation then failed to
open its own screen, because the game was never actually back at a
known state. Fixed two ways: RESULT_MODAL_MAX_POLLS widened
substantially to tolerate a real battle's full duration regardless of
whether the skip-mode bug gets fixed separately, and a new
_back_on_challenge_list early exit so this widened budget doesn't also
slow down the normal fast-skip-mode case -- the loop now exits as soon
as a fight is genuinely confirmed over, not after a fixed guess.
"""
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
if _back_on_challenge_list(driver, config):
return True
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 _back_on_challenge_list(driver, config)
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)
# Real bug found alongside the _wait_for_result fixes above
# (2026-07-17): this used to discard _wait_for_result's return value
# and always report success regardless, so even a detected problem --
# including the dangerous opponent-info-modal case its own hard safety
# gate exists to catch -- never actually stopped run()'s fight loop
# from proceeding to the next ticket.
if not _wait_for_result(driver, config):
print("[arena] result not confirmed after the fight -- stopping without attempting further fights")
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}")
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.")