- 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.
458 lines
22 KiB
Python
458 lines
22 KiB
Python
"""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.")
|