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

458 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Bounty (指名手配). Reference: baas-reference/module/rewarded_task.py.
Ported per explicit user direction (2026-07-11): 3 areas (ハイウェイ/砂漠の
線路/校舎 -- this client's rendering of the reference's OVERPASS/DESSERT
RAILWAY/CLASSROOM), choose one via the same date-ordinal-modulo rotation
story_sweep.py/event_sweep.py already use, and always sweep that area's
latest/highest-numbered stage. Rather than the reference's own per-call loop
across all 3 areas by a configured sweep-count-per-area
(get_task_count/rewarded_task_times -- config this project has no
equivalent for) plus its get_los/one_detect star-color SSS-availability
scan, this sweeps exactly one area's one (always-latest) stage per run,
mirroring event_sweep.py's own simplification of its reference
(sweep_activity.py) for the same reason.
Live-calibrated against nik-gpu 2026-07-11, zero real tickets spent (every
confirm dialog reached was cancelled via Escape, ticket count 6/6 confirmed
unchanged before/after, across all 3 areas):
- Entry: WORK_ICON -> config.BOUNTY_CARD click lands directly on the
Location Select screen -- no separate bus-icon/sub-navigation step needed
on this client, unlike the reference's bottom-nav "main_page_bus" icon.
指名手配 is a Work-hub card here, matching story_sweep's Task card /
arena's Tactical Challenge card.
- Location Select's 3 area rows are a fixed list, not scrollable/paged --
config.BOUNTY_AREA_ROW_Y indexes directly by rotation.
- Each area's stage list is forced to its bottom scroll extreme every run
(not just trusted to already be there, per event_sweep.py's own
reasoning for the same kind of state). Confirmed live across all 3 areas:
exactly 10 stages per area (numbered 01-10, lettered A-J), row 10 is the
true list end (scrolling further is a no-op), and every stage was already
3-starred/SSS-cleared. This makes the bottom-most row always literally
"the latest stage available" by construction -- unlike story_sweep/
event_sweep, no OCR search is needed to locate a specific target within a
longer-than-visible list.
- No never-cleared stage was available to confirm the reference's
SSS-availability gate against (every stage on this account already was).
This reuses story_sweep/event_sweep's same defensive fallback instead: if
the MAX-button count-raise can't be verified, abort without spending a
ticket rather than guess.
- The 任務情報 (task info) modal's MAX/count stepper and the ticket-usage
confirm dialog ("指名手配チケットをN使用して...") are pixel-identical in
position/color to event_sweep's own stage modal/SWEEP_CONFIRM_* dialog --
confirmed live -- and this reuses SWEEP_CONFIRM_* directly rather than
re-declaring duplicates (same pattern story_sweep/event_sweep already
share that dialog with). Confirmed live this modal DOES close on Escape,
like event_sweep's (not story_sweep's, which needs its own X button).
- The modal-open probe could NOT be reused from event_sweep's
EVENT_STAGE_MODAL_PROBE -- that corner point reads dark on this screen
regardless of modal state, since the underlying background art differs.
config.BOUNTY_STAGE_MODAL_PROBE is a freshly calibrated point that does
discriminate both ways.
Live-tested for real (2026-07-11, 2 real sweeps, credits gained both times,
clean automatic return home both times), which found and fixed two real
bugs -- see plan.md's Bounty phase (Phase 15) for the full writeup:
1. `_set_sweep_count`'s original count==1 path wrongly assumed the modal
always defaults to count=1 on open and skipped clicking anything -- the
modal actually remembers the last-used count across stages/areas, so
this silently confirmed a real 5-ticket sweep instead of the intended 1.
Fixed via `_click_min_and_verify`, which always forces a known baseline
(the MIN button, symmetric to the existing MAX click) before applying any
"+" raises, for any count including 1.
2. config.BOUNTY_SWEEP_RESULT_BUTTON_REGION's first-guess copy of
event_sweep's own region overlapped BOUNTY_SWEEP_START_BUTTON's real
cyan pixels. Once a sweep dropped the ticket count to exactly 0,
_find_result_button re-matched that button's corner after the real
result dialog had already closed, and clicking it surfaced a REAL
Pyroxene ticket-purchase prompt (the same shared gold-button dialog
component as the AP tasks' insufficient-AP variant). No Pyroxene was
actually spent (gem balance confirmed unchanged), but this was a real
near-miss, not a hypothetical one. Fixed three ways: the region was
corrected to the real, pixel-scanned OK-button bbox; a second, smaller
contamination source (stray cyan-range pixels in the modal's own
reward-icon artwork, confirmed live) is filtered via a new min_pixels
parameter on detector.find_color_centroid
(config.BOUNTY_RESULT_BUTTON_MIN_PIXELS); and _watch_sweep_result now has
an explicit _is_ticket_purchase_prompt check as one of its own `ends`
conditions, so this state is recognized and safely cancelled outright if
it's ever reached again, rather than left to a blind color search near a
real-currency dialog.
NOT yet re-confirmed live: a fresh sweep with these fixes deployed (the
account was left at 0/6 tickets by this session's own testing) and any
bulk/MAX-count sweep's result-screen flow specifically -- only count=1 was
ever tested (ticket scarcity forced it), so it's not yet confirmed whether a
bulk sweep shows the same single-OK dialog or a SKIP-then-OK sequence like
story_sweep/event_sweep's own bulk-sweep result screens.
Real hazard found and fixed via a real unattended `daily` cron run
(2026-07-17), reported live by the user: a real "Battle Complete" screen (a
live combat timer, ~3 minutes) was found on the account instead of an
instant sweep having happened, and every task that ran afterward in that
same `daily` invocation failed to open its own screen for the rest of the
run -- this task's own `_watch_sweep_result` had reported "swept" cleanly,
with no warnings anywhere in the log. Manually reproducing the flow live
(reaching the real confirm dialog and cancelling before confirming, same
safe-calibration pattern as the original Phase 15 work) found the actual
hazard: the 任務情報 modal has TWO separate action buttons stacked
vertically -- the intended cyan 掃討開始 (start sweep, instant) and a
separate gold 任務開始 (start mission, a REAL manual battle) directly below
it, both showing an identical "N→N-1" ticket-cost preview. Every check
along this task's own commit path (`_count_raised_above_one`,
`_is_sweep_usage_confirm`, `_watch_sweep_result`'s own "swept" conditions)
is a generic color/position probe with no verification of WHAT is actually
showing -- the exact same class of false-positive risk that repeatedly
fooled login.py's single/few-point checks the same day (see plan.md's
Phase 18 follow-up #3), just with much higher stakes here (an actual
battle, not a misread loading screen). The precise mechanism that let a
real run reach 任務開始 instead of 掃討開始 was not fully reproduced live
(doing so would mean deliberately repeating a real battle) -- but the fix
closes the hazard regardless of the exact upstream cause: `_confirm_dialog_
is_sweep` OCR-verifies the confirm dialog's actual text
("指名手配チケットをN使用して、掃討をN回行いますか", captured live from
the real dialog) before the one irreversible click in this whole flow, and
cancels rather than confirms if it doesn't read as a sweep. Not yet
re-verified live against a fresh real sweep (would need to spend another
real ticket) -- the dialog-text capture and cancel-without-confirming were
both done live, but the actual gate has not yet been exercised by a
genuine full run.
"""
import datetime
from ba_auto import detector, navigation
OPEN_RETRIES = 3
AREA_RETRIES = 3
STAGE_ENTER_RETRIES = 3
MAX_BUTTON_RETRIES = 3
MODAL_CLOSE_RETRIES = 3
SWEEP_START_RETRIES = 3
POST_SWEEP_DISMISS_ROUNDS = 14
def _is_stage_modal_open(driver, config):
r, g, b = driver.color_at(*config.BOUNTY_STAGE_MODAL_PROBE)
return r < config.BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL and g < config.BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL and b < config.BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL
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 _is_sweep_usage_confirm(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_CYAN)
def _is_ticket_purchase_prompt(driver, config):
return _color_in_range(driver.color_at(*config.SWEEP_CONFIRM_BUTTON), config.SWEEP_CONFIRM_GOLD)
def _confirm_dialog_is_sweep(driver, config):
"""OCR-verify the confirm dialog reached after clicking 掃討開始 is
genuinely the sweep-usage confirm ("指名手配チケットをN使用して、掃討を
N回行いますか"), not some other cyan-styled confirmation that happens
to satisfy _is_sweep_usage_confirm's color-only check -- see
config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real incident this
guards against (a real battle got triggered instead of a sweep, and
every check along that path was a generic color/position probe with no
check on what was actually showing). Substring match on "掃討" rather
than an exact match, matching this project's own established
dialog-classification convention (cafe.py's 衣装/隣 checks,
event_sweep.py's "終了" check).
"""
text = detector.read_text(config.BOUNTY_SWEEP_CONFIRM_TEXT_RECT, psm=6, lang="jpn")
return "掃討" in text
def _find_result_button(driver, config):
return detector.find_color_centroid(
config.BOUNTY_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN,
min_pixels=config.BOUNTY_RESULT_BUTTON_MIN_PIXELS,
)
def _count_raised_above_one(driver, config):
# Same coral/orange color-spread check as event_sweep's own minus-button
# probe -- confirmed live to read the exact same colors here,
# (171,172,171) default vs (251,173,152) raised.
r, g, b = driver.color_at(*config.BOUNTY_SWEEP_MINUS_BUTTON_PROBE)
return (max(r, g, b) - min(r, g, b)) > 40
def _open_bounty_screen(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"[bounty] work hub not detected after click (attempt {attempt}/{OPEN_RETRIES})")
else:
return False
for attempt in range(1, OPEN_RETRIES + 1):
driver.click(*config.BOUNTY_CARD)
driver.wait(2)
if navigation.is_on_subscreen(driver):
return True
print(f"[bounty] location select not detected after click (attempt {attempt}/{OPEN_RETRIES})")
return False
def _open_area(driver, config, area_index):
row_y = config.BOUNTY_AREA_ROW_Y[area_index]
for attempt in range(1, AREA_RETRIES + 1):
driver.click(config.BOUNTY_AREA_ROW_X, row_y)
driver.wait(2)
if navigation.is_on_subscreen(driver):
return True
print(f"[bounty] stage list not detected after clicking area {area_index} (attempt {attempt}/{AREA_RETRIES})")
return False
def _row_number_rect(config):
x1, x2 = config.BOUNTY_STAGE_NUMBER_OCR_X
top_pad, bottom_pad = config.BOUNTY_STAGE_NUMBER_OCR_Y_PAD
row_y = config.BOUNTY_LATEST_STAGE_ROW_Y
return (x1, row_y - top_pad, x2, row_y + bottom_pad)
def _open_latest_stage_modal(driver, config):
x, y = config.BOUNTY_STAGE_LIST_SCROLL_POINT
driver.scroll(x, y, "down", config.BOUNTY_STAGE_LIST_SCROLL_CLICKS)
driver.wait(0.5)
label = detector.read_int(_row_number_rect(config))
print(f"[bounty] latest stage row reads '{label}' (diagnostic only -- selection is always the bottom-most row)")
row_y = config.BOUNTY_LATEST_STAGE_ROW_Y
for attempt in range(1, STAGE_ENTER_RETRIES + 1):
driver.click(config.BOUNTY_STAGE_ENTER_X, row_y)
driver.wait(2)
if _is_stage_modal_open(driver, config):
return True
print(f"[bounty] stage info panel not detected after click (attempt {attempt}/{STAGE_ENTER_RETRIES})")
return False
def _click_max_and_verify(driver, config):
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
driver.click(*config.BOUNTY_SWEEP_MAX_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[bounty] MAX click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _click_min_and_verify(driver, config):
# Force a known baseline before applying "+" clicks -- confirmed live
# (2026-07-11 live-test bug) that the modal does NOT reliably default to
# count=1 on open: it carried over count=6 from an earlier calibration
# session on a completely different stage/area, and a since-removed
# "count==1 means skip, no click needed" shortcut trusted that false
# default and silently confirmed a real 5-ticket sweep instead of the
# intended 1. MIN reliably jumps to the minimum the same way MAX jumps
# to the maximum (the two are symmetric stepper endpoints) -- unlike
# MAX, though, "back at the minimum" can't be verified via
# _count_raised_above_one (that check's whole point is detecting a
# raise above 1), so this checks the inverse: the minus-button probe
# back at its flat, non-raised color.
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
driver.click(*config.BOUNTY_SWEEP_MIN_BUTTON)
driver.wait(0.8)
if not _count_raised_above_one(driver, config):
return True
print(f"[bounty] MIN click not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _click_plus_and_verify(driver, config, count):
if not _click_min_and_verify(driver, config):
print("[bounty] could not confirm count was reset to the minimum before raising it -- aborting rather than risk an unverified starting count")
return False
if count == 1:
return True
for attempt in range(1, MAX_BUTTON_RETRIES + 1):
for _ in range(count - 1):
driver.click(*config.BOUNTY_SWEEP_PLUS_BUTTON)
driver.wait(0.8)
if _count_raised_above_one(driver, config):
return True
print(f"[bounty] count-raise via '+' not detected (attempt {attempt}/{MAX_BUTTON_RETRIES})")
return False
def _set_sweep_count(driver, config, count):
if count == "max":
return _click_max_and_verify(driver, config)
return _click_plus_and_verify(driver, config, count)
def _close_stage_modal(driver, config):
# Confirmed live this modal DOES close on Escape (like event_sweep's) --
# try Escape first, fall back to the X button if it somehow doesn't clear.
for _ in range(MODAL_CLOSE_RETRIES):
if not _is_stage_modal_open(driver, config):
return True
driver.keypress("Escape")
driver.wait(1)
if not _is_stage_modal_open(driver, config):
return True
driver.click(*config.BOUNTY_STAGE_MODAL_CLOSE_BUTTON)
driver.wait(1)
return not _is_stage_modal_open(driver, config)
def _watch_sweep_result(driver, config):
# Same clicked_any-gated two-ends-condition pattern event_sweep.py's own
# _watch_sweep_result uses, since this modal shares that same underlying
# "may auto-return past the bare stage modal all the way to the list"
# risk -- confirmed live (2026-07-11, count=1): after the single "OK"
# result button is clicked, the flow returns to the bare stage-info
# modal (not all the way to the list), so the first `ends` condition is
# the one that actually fires in practice, but the second is kept as a
# defensive fallback in case a bulk/MAX sweep (not yet live-tested)
# behaves differently.
#
# ALSO confirmed live (same run): once held tickets hit exactly 0,
# re-clicking 掃討開始 (which _find_result_button's region used to
# overlap -- see BOUNTY_SWEEP_RESULT_BUTTON_REGION's own comment for the
# false-positive this caused) surfaces a REAL Pyroxene ticket-purchase
# dialog, the same shared "通知" component as SWEEP_CONFIRM_GOLD's
# insufficient-AP variant elsewhere in this project. The region fix
# + find_color_centroid's new min_pixels threshold should prevent
# _find_result_button from ever clicking into that button's footprint
# again, but this explicit check is kept as a second, independent layer
# -- if this state is ever reached anyway, cancel it outright rather
# than let the loop keep hunting for cyan pixels near a real-currency
# purchase button. No ticket/Pyroxene was actually spent when this was
# found live (confirmed by an unchanged gem balance before/after), but
# this closes the gap rather than relying on that being luck.
clicked_any = {"value": False}
def click_result_button(d):
pos = _find_result_button(d, config)
if pos:
d.click(*pos)
clicked_any["value"] = True
d.wait(1.5)
ends = {
(lambda d, c: _is_ticket_purchase_prompt(d, c)): "prompted_to_purchase",
(lambda d, c: _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
(lambda d, c: clicked_any["value"] and not _is_stage_modal_open(d, c) and _find_result_button(d, c) is None): "swept",
}
reactions = {
(lambda d, c: _find_result_button(d, c) is not None): click_result_button,
}
outcome = navigation.wait_for_state(
driver, config, reactions, ends,
max_iterations=POST_SWEEP_DISMISS_ROUNDS, poll_interval=1.5,
)
return outcome or "unrecognized_state"
def _click_sweep_start_and_verify(driver, config):
for attempt in range(1, SWEEP_START_RETRIES + 1):
driver.click(*config.BOUNTY_SWEEP_START_BUTTON)
driver.wait(1.5)
if _is_sweep_usage_confirm(driver, config) or _is_ticket_purchase_prompt(driver, config):
return True
print(f"[bounty] sweep confirm/ticket-purchase dialog not detected after 掃討開始 click (attempt {attempt}/{SWEEP_START_RETRIES})")
return False
def _sweep_latest_stage(driver, config, area_index, count):
area_name = config.BOUNTY_AREA_NAMES[area_index]
print(f"[bounty] --- area {area_index} ({area_name}), latest stage x {count} ---")
if not _open_area(driver, config, area_index):
return "area_unavailable"
if not _open_latest_stage_modal(driver, config):
print("[bounty] stage info panel not detected, aborting")
return "unrecognized_state"
if not _set_sweep_count(driver, config, count):
print("[bounty] could not confirm sweep count was raised (stage may not be SSS-cleared/sweepable yet) -- aborting without spending a ticket")
_close_stage_modal(driver, config)
return "not_sweepable"
if not _click_sweep_start_and_verify(driver, config):
print("[bounty] sweep-usage confirmation not detected, aborting without further input")
_close_stage_modal(driver, config)
return "unrecognized_state"
if _is_ticket_purchase_prompt(driver, config):
print("[bounty] insufficient bounty tickets for this sweep -- cancelling without purchasing")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
_close_stage_modal(driver, config)
return "inadequate_ticket"
# Hard safety gate before the one irreversible click in this whole
# flow -- see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT for the real
# incident (a real battle got triggered instead of a sweep) this
# closes. _is_sweep_usage_confirm already passed (a color match), but
# that alone isn't proof this is really the sweep dialog -- verify the
# actual text before committing rather than trusting color alone.
if not _confirm_dialog_is_sweep(driver, config):
print("[bounty] confirm dialog text did not read as a sweep confirmation -- cancelling without confirming (see config.py's BOUNTY_SWEEP_CONFIRM_TEXT_RECT)")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
_close_stage_modal(driver, config)
return "unrecognized_state"
driver.click(*config.SWEEP_CONFIRM_BUTTON)
driver.wait(1.5)
print("[bounty] sweep confirmed, waiting for results")
outcome = _watch_sweep_result(driver, config)
print(f"[bounty] result: {outcome}")
if outcome == "prompted_to_purchase":
# See _watch_sweep_result's own comment -- cancel this real-currency
# dialog explicitly rather than let _close_stage_modal's plain
# Escape loop be the only thing standing between it and a real
# Pyroxene spend.
print("[bounty] ticket-purchase prompt detected after the sweep -- cancelling without purchasing")
driver.click(*config.SWEEP_CONFIRM_CANCEL_BUTTON)
driver.wait(1)
if not _close_stage_modal(driver, config):
print("[bounty] warning: could not confirm stage info modal closed -- leaving it open rather than pressing further keys blindly")
return outcome
def _rotation_area(config):
area_count = len(config.BOUNTY_AREA_NAMES)
return datetime.date.today().toordinal() % area_count
def run(driver, config):
driver.focus_game()
# Reused from arena.py's own fix for the same class of bug (see
# plan.md's Phase 13 follow-up): WORK_ICON's click is home-relative, so
# a prior run left mid-navigation would send it to the wrong place.
navigation.return_to_home(driver)
area_index = _rotation_area(config)
print(f"[bounty] today's rotation target: area {area_index} ({config.BOUNTY_AREA_NAMES[area_index]})")
if not _open_bounty_screen(driver, config):
print("[bounty] could not confirm bounty location-select screen is open, aborting without pressing further keys")
navigation.return_to_home(driver)
return
outcome = _sweep_latest_stage(driver, config, area_index, config.BOUNTY_SWEEP_COUNT)
if outcome != "swept":
print(f"[bounty] area {area_index} ended in '{outcome}'")
if not navigation.return_to_home(driver):
print("[bounty] warning: could not confirm return to home screen")
print("[bounty] Done.")