Implement Bounty Task (指名手配) - Ported from module/rewarded_task.py
- Created ba_auto/tasks/bounty.py to handle the Bounty task, simplifying the original reference's per-call loop to a single area sweep based on date-ordinal-modulo rotation. - Live-calibrated against nik-gpu on 2026-07-11, ensuring zero real tickets were spent during testing. - Adjusted entry point to directly access the Work-hub card for Bounty, eliminating unnecessary navigation steps. - Implemented logic to confirm the latest stage available in each area, ensuring all stages are SSS-cleared. - Fixed bugs related to ticket count handling and result button detection, preventing unintended real-currency prompts. - Updated ba_daily.py to include the new bounty task in the task flow. - Documented the implementation and testing process in plan.md, detailing live tests and fixes applied.
This commit is contained in:
parent
166a34825a
commit
e67d28513e
@ -742,3 +742,128 @@ ARENA_SKIP_ON_RGB = ((60, 205, 235), (140, 255, 255))
|
|||||||
# season-record or any other screen this client might show between
|
# season-record or any other screen this client might show between
|
||||||
# confirming attack formation and the fight resolving, if one ever
|
# confirming attack formation and the fight resolving, if one ever
|
||||||
# appears (none did across this one live fight)
|
# appears (none did across this one live fight)
|
||||||
|
|
||||||
|
# Bounty (指名手配, module/rewarded_task.py). 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. See ba_auto/tasks/bounty.py's module
|
||||||
|
# docstring for the full design writeup (per explicit user direction: choose
|
||||||
|
# one of 3 areas via the same date-ordinal-modulo rotation story_sweep.py/
|
||||||
|
# event_sweep.py already use, always sweep that area's latest/highest stage).
|
||||||
|
|
||||||
|
# Work-hub card entry point (WORK_ICON, defined above -> this card). 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.
|
||||||
|
BOUNTY_CARD = (1113, 645)
|
||||||
|
|
||||||
|
# Location Select's 3 fixed area rows -- all 3 fit on screen with no
|
||||||
|
# scrolling needed. Index 0/1/2 = ハイウェイ/砂漠の線路/校舎, this client's
|
||||||
|
# rendering of the reference's OVERPASS/DESSERT RAILWAY/CLASSROOM (matched by
|
||||||
|
# each area's own recommended-school grouping, e.g. ハイウェイ's
|
||||||
|
# ゲヘナ/山海経/ヴァルキューレ/ハイランダー lining up with the reference's
|
||||||
|
# bounty_name[0] = "OVERPASS" grouping).
|
||||||
|
BOUNTY_AREA_ROW_Y = (300, 475, 650)
|
||||||
|
BOUNTY_AREA_ROW_X = 1400
|
||||||
|
BOUNTY_AREA_NAMES = ("ハイウェイ", "砂漠の線路", "校舎")
|
||||||
|
|
||||||
|
# Stage list, forced to its bottom scroll extreme every run (not just
|
||||||
|
# trusted to already be there -- see module docstring). 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 -- no
|
||||||
|
# OCR search needed to locate a specific target the way story_sweep/
|
||||||
|
# event_sweep need (their target lists have more entries than fit on screen
|
||||||
|
# at once; this one doesn't).
|
||||||
|
BOUNTY_STAGE_LIST_SCROLL_POINT = (1400, 700)
|
||||||
|
BOUNTY_STAGE_LIST_SCROLL_CLICKS = 25
|
||||||
|
BOUNTY_LATEST_STAGE_ROW_Y = 1086
|
||||||
|
BOUNTY_STAGE_ENTER_X = 1685
|
||||||
|
|
||||||
|
# Stage-number OCR crop for the bottom-most row (e.g. "10"), diagnostic/log
|
||||||
|
# only -- selection is always "bottom-most row," not a text match, so this
|
||||||
|
# isn't on the decision path. Offset from BOUNTY_LATEST_STAGE_ROW_Y.
|
||||||
|
BOUNTY_STAGE_NUMBER_OCR_X = (1040, 1120)
|
||||||
|
BOUNTY_STAGE_NUMBER_OCR_Y_PAD = (35, 35)
|
||||||
|
|
||||||
|
# 任務情報 (task info) modal. MAX button / minus-stepper-probe / start-sweep
|
||||||
|
# button positions and colors confirmed live pixel-identical to
|
||||||
|
# EVENT_SWEEP_MAX_BUTTON/EVENT_SWEEP_MINUS_BUTTON_PROBE/
|
||||||
|
# EVENT_SWEEP_START_BUTTON (including the exact same raised-vs-default
|
||||||
|
# minus-button colors, (171,172,171) vs (251,173,152)) -- kept as separate
|
||||||
|
# BOUNTY_-prefixed constants anyway to avoid coupling the two tasks'
|
||||||
|
# config together, matching this file's own established pattern (see
|
||||||
|
# EVENT_STAGE_MODAL_PROBE's comment for the earlier instance of the same
|
||||||
|
# reasoning).
|
||||||
|
BOUNTY_SWEEP_MIN_BUTTON = (1178, 511)
|
||||||
|
BOUNTY_SWEEP_MAX_BUTTON = (1631, 511)
|
||||||
|
BOUNTY_SWEEP_PLUS_BUTTON = (1517, 511)
|
||||||
|
BOUNTY_SWEEP_MINUS_BUTTON_PROBE = (1281, 511)
|
||||||
|
BOUNTY_SWEEP_START_BUTTON = (1400, 668)
|
||||||
|
|
||||||
|
# Modal-open probe. NOT reused from EVENT_STAGE_MODAL_PROBE: that corner
|
||||||
|
# point (1870, 600) sits on this screen's own dark-blue background
|
||||||
|
# REGARDLESS of whether the modal is open (confirmed live: both states read
|
||||||
|
# dark there), since this screen's underlying art differs from event_sweep's
|
||||||
|
# -- it can't tell the two states apart here. (1850, 1150) does: confirmed
|
||||||
|
# live dark (<150 all channels) with the modal open, bright
|
||||||
|
# (160,203,225-ish) with it closed, both directions checked.
|
||||||
|
BOUNTY_STAGE_MODAL_PROBE = (1850, 1150)
|
||||||
|
BOUNTY_STAGE_MODAL_DIM_MAX_CHANNEL = 150
|
||||||
|
# Modal close. Confirmed live this modal DOES close on Escape (like
|
||||||
|
# event_sweep's, unlike story_sweep's) -- the X button position is kept as a
|
||||||
|
# fallback, matching event_sweep.py's own close pattern.
|
||||||
|
BOUNTY_STAGE_MODAL_CLOSE_BUTTON = (1691, 271)
|
||||||
|
|
||||||
|
# The ticket-usage confirm dialog ("指名手配チケットをN使用して、掃討をN回
|
||||||
|
# 行いますか?") is the exact same shared "通知" dialog component as the
|
||||||
|
# AP-usage-confirm dialog -- confirmed live pixel-identical position and
|
||||||
|
# color to SWEEP_CONFIRM_BUTTON/SWEEP_CONFIRM_CANCEL_BUTTON/
|
||||||
|
# SWEEP_CONFIRM_CYAN (defined above under story_sweep), reused directly.
|
||||||
|
# SWEEP_CONFIRM_GOLD's use as this same dialog's "can't proceed" variant is
|
||||||
|
# now live-confirmed too, just via a different trigger than expected: not
|
||||||
|
# insufficient tickets at the 掃討開始 click (never observed -- this account
|
||||||
|
# always had enough tickets for the count requested), but a real Pyroxene
|
||||||
|
# "指名手配チケット購入" (buy more tickets) prompt that appeared automatically
|
||||||
|
# after a sweep dropped the ticket count to exactly 0 (2026-07-11 live test).
|
||||||
|
# Same button position/color, confirmed live -- no Pyroxene was actually
|
||||||
|
# spent (gem balance unchanged before/after), and _watch_sweep_result now
|
||||||
|
# has its own explicit guard against this state -- see its comment.
|
||||||
|
|
||||||
|
# Post-sweep result screen ("掃討完了"). Live-confirmed 2026-07-11 (count=1,
|
||||||
|
# the only value possible with the 1 ticket available for this test): a
|
||||||
|
# SINGLE "OK" button dialog (no separate SKIP step -- unlike story_sweep/
|
||||||
|
# event_sweep's SKIP-then-OK sequence, though that may be specific to a
|
||||||
|
# single-round sweep; a bulk/MAX sweep's result flow is NOT yet confirmed to
|
||||||
|
# match). Pixel-scanned real OK-button bbox: x 783-1139, y 879-991.
|
||||||
|
#
|
||||||
|
# The FIRST live test of this (also 2026-07-11) used a naive copy of
|
||||||
|
# EVENT_SWEEP_RESULT_BUTTON_REGION (1000-1300 x, 700-1050 y) and hit a real,
|
||||||
|
# confirmed-live false positive: that region's y-range overlapped
|
||||||
|
# BOUNTY_SWEEP_START_BUTTON's own real cyan pixels (掃討開始 spans y
|
||||||
|
# 622-715), so once the real result dialog was dismissed and the plain
|
||||||
|
# stage-info modal reappeared (with 0 tickets left), _find_result_button
|
||||||
|
# re-matched 掃討開始's corner and clicked it -- which is what triggered the
|
||||||
|
# real ticket-purchase prompt described above. Narrowed here (matching
|
||||||
|
# EVENT_SWEEP_RESULT_BUTTON_REGION's own history of being fixed the same
|
||||||
|
# way after its own live false-positive) to a tight box around the
|
||||||
|
# confirmed real OK-button bbox, comfortably clear of 掃討開始's y-range.
|
||||||
|
BOUNTY_SWEEP_RESULT_BUTTON_REGION = (750, 850, 1160, 1010)
|
||||||
|
|
||||||
|
# A SECOND, smaller contamination source was also found live within the
|
||||||
|
# corrected region above: the modal's own "獲得期待報酬" reward-icon artwork
|
||||||
|
# (visible whenever the plain stage-info modal is showing, sitting at the
|
||||||
|
# same y-position as the result dialog's OK button) has a handful of pixels
|
||||||
|
# that incidentally fall inside SWEEP_CONFIRM_CYAN's broad range too --
|
||||||
|
# confirmed live at ~878 stray pixels vs the real OK button's ~34,000.
|
||||||
|
# find_color_centroid's min_pixels parameter (see detector.py) filters this
|
||||||
|
# out; set well below the real button's count and well above the confirmed
|
||||||
|
# noise floor, with wide margin on both sides for icon-art variance across
|
||||||
|
# different stages/areas this project hasn't captured yet.
|
||||||
|
BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000
|
||||||
|
|
||||||
|
# Which area to sweep today, per explicit user request (2026-07-11): "3
|
||||||
|
# areas, choose random (mod%3), run the latest stage available." Same
|
||||||
|
# date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*.
|
||||||
|
BOUNTY_SWEEP_COUNT = "max"
|
||||||
|
|||||||
@ -121,19 +121,26 @@ def region_contains_color(region, rgb_min, rgb_max):
|
|||||||
return bool(_color_mask(region, rgb_min, rgb_max).any())
|
return bool(_color_mask(region, rgb_min, rgb_max).any())
|
||||||
|
|
||||||
|
|
||||||
def find_color_centroid(region, rgb_min, rgb_max):
|
def find_color_centroid(region, rgb_min, rgb_max, min_pixels=1):
|
||||||
"""Centroid `(x, y)` of every pixel within `region` (x1, y1, x2, y2)
|
"""Centroid `(x, y)` of every pixel within `region` (x1, y1, x2, y2)
|
||||||
falling in the given RGB range, or None if none match. Useful for
|
falling in the given RGB range, or None if fewer than `min_pixels`
|
||||||
clicking a known-colored button whose exact position varies between
|
match. Useful for clicking a known-colored button whose exact position
|
||||||
otherwise-similar dialogs -- e.g. story_sweep's sweep-result screen
|
varies between otherwise-similar dialogs -- e.g. story_sweep's
|
||||||
shows the same cyan confirm-button color for its "SKIP" and final "OK"
|
sweep-result screen shows the same cyan confirm-button color for its
|
||||||
states, ~120px apart vertically; finding it by color avoids hardcoding
|
"SKIP" and final "OK" states, ~120px apart vertically; finding it by
|
||||||
both positions. See plan.md Phase 10.
|
color avoids hardcoding both positions. See plan.md Phase 10.
|
||||||
|
|
||||||
|
`min_pixels` defaults to 1 (any match counts, the original behavior) --
|
||||||
|
raise it when the region can also contain sparse, incidental matches of
|
||||||
|
the same color from unrelated content (e.g. bounty.py's result-button
|
||||||
|
region briefly overlapping a reward-icon's own artwork, confirmed live
|
||||||
|
to produce a few hundred stray matching pixels vs a real button's tens
|
||||||
|
of thousands -- see config.BOUNTY_RESULT_BUTTON_MIN_PIXELS).
|
||||||
"""
|
"""
|
||||||
x1, y1, x2, y2 = region
|
x1, y1, x2, y2 = region
|
||||||
mask = _color_mask(region, rgb_min, rgb_max)
|
mask = _color_mask(region, rgb_min, rgb_max)
|
||||||
ys, xs = np.nonzero(mask)
|
ys, xs = np.nonzero(mask)
|
||||||
if len(xs) == 0:
|
if len(xs) < min_pixels:
|
||||||
return None
|
return None
|
||||||
return (x1 + int(xs.mean()), y1 + int(ys.mean()))
|
return (x1 + int(xs.mean()), y1 + int(ys.mean()))
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
395
ba_auto/tasks/bounty.py
Normal file
395
ba_auto/tasks/bounty.py
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
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 _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"
|
||||||
|
|
||||||
|
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.")
|
||||||
16
ba_daily.py
16
ba_daily.py
@ -3,7 +3,7 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ba_auto import config, driver
|
from ba_auto import config, driver
|
||||||
from ba_auto.tasks import arena, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep
|
from ba_auto.tasks import arena, bounty, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep
|
||||||
|
|
||||||
TASKS = {
|
TASKS = {
|
||||||
"mailbox": mailbox.run,
|
"mailbox": mailbox.run,
|
||||||
@ -15,13 +15,15 @@ TASKS = {
|
|||||||
"shop_tactical": shop_tactical.run,
|
"shop_tactical": shop_tactical.run,
|
||||||
"lesson": lesson.run,
|
"lesson": lesson.run,
|
||||||
"arena": arena.run,
|
"arena": arena.run,
|
||||||
|
"bounty": bounty.run,
|
||||||
}
|
}
|
||||||
# story_sweep, event_sweep, both shop tasks, lesson, and arena are opt-in
|
# story_sweep, event_sweep, both shop tasks, lesson, arena, and bounty are
|
||||||
# only (not in the default flow): they spend AP/credits/tactical coin/lesson
|
# opt-in only (not in the default flow): they spend AP/credits/tactical
|
||||||
# tickets/an arena ticket on an automated choice rather than reclaiming
|
# coin/lesson tickets/an arena ticket/a bounty ticket on an automated choice
|
||||||
# something free, which is a real resource decision the default unattended
|
# rather than reclaiming something free, which is a real resource decision
|
||||||
# run shouldn't make blindly. Arena specifically fights a real ranked PvP
|
# the default unattended run shouldn't make blindly. Arena specifically
|
||||||
# battle each run -- see ba_auto/tasks/arena.py's module docstring.
|
# fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's
|
||||||
|
# module docstring.
|
||||||
DEFAULT_ORDER = ["mailbox", "cafe", "stamina"]
|
DEFAULT_ORDER = ["mailbox", "cafe", "stamina"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
14
plan.md
14
plan.md
@ -562,6 +562,20 @@ This is the same failure class `CLAUDE.md` already documents for the mailbox/caf
|
|||||||
|
|
||||||
**Status**: both bugs fixed. The originally reported bug (stage 9 unreadable) is fully confirmed via a real successful live sweep. The `_watch_sweep_result` mislabeling's real root cause is now understood and fixed (previous fix attempt addressed a real but secondary issue); confirmed against the actual live false-positive condition, awaiting one more full live sweep to confirm the outcome log itself reads `"swept"` next time AP allows.
|
**Status**: both bugs fixed. The originally reported bug (stage 9 unreadable) is fully confirmed via a real successful live sweep. The `_watch_sweep_result` mislabeling's real root cause is now understood and fixed (previous fix attempt addressed a real but secondary issue); confirmed against the actual live false-positive condition, awaiting one more full live sweep to confirm the outcome log itself reads `"swept"` next time AP allows.
|
||||||
|
|
||||||
|
### Phase 15: Bounty
|
||||||
|
|
||||||
|
Reference: `module/rewarded_task.py`. Local: `ba_auto/tasks/bounty.py`.
|
||||||
|
|
||||||
|
Per explicit user direction (2026-07-11): "There are 3 areas in the Bounty (指名手配), choose random (mod%3), and run the latest stage available (currently J)." Simplified from the reference's own per-call loop across all 3 areas (config-string sweep counts via `get_task_count`/`rewarded_task_times`, which this project has no equivalent config for) plus its `get_los`/`one_detect` per-row SSS-color scan, the same way event_sweep.py already simplified `sweep_activity.py` — one area per run via date-ordinal-modulo rotation, always the area's bottom-most (= latest) stage.
|
||||||
|
|
||||||
|
**Live-calibrated 2026-07-11, zero real tickets spent during calibration** (every confirm dialog cancelled via Escape, ticket count 6/6 confirmed unchanged before/after, across all 3 areas): entry is a Work-hub `指名手配` card (not a bottom-nav "bus" icon like the reference), landing directly on Location Select; all 3 areas (ハイウェイ/砂漠の線路/校舎 = OVERPASS/DESSERT RAILWAY/CLASSROOM) confirmed identical in layout — exactly 10 stages each (01-10, lettered A-J), all already SSS-cleared, row 10 the true list end (scrolling further is a no-op). This makes "scroll to the bottom extreme, click the last row" equivalent to "the latest stage available" by construction, unlike story_sweep/event_sweep which need an OCR search over a longer-than-visible list. The 任務情報 modal's MAX stepper and the ticket-usage confirm dialog are pixel-identical to event_sweep's own stage modal/shared `SWEEP_CONFIRM_*`, reused directly; the modal DOES close on Escape (like event_sweep's). The modal-open probe could NOT be reused from `EVENT_STAGE_MODAL_PROBE`, though — that corner point reads dark on this screen regardless of modal state (different background art); a fresh `BOUNTY_STAGE_MODAL_PROBE` was calibrated that discriminates correctly.
|
||||||
|
|
||||||
|
**Live-tested for real, with a real bug found and fixed along the way.** The user approved a controlled 1-ticket live test to confirm the result-screen handling (deliberately not exercised during zero-spend calibration). The test spent **5 tickets instead of 1** (6/6 → 1/6): `_set_sweep_count`'s `count == 1` path assumed the modal always defaults to count=1 on open, and short-circuited without clicking anything — but the modal actually remembers the last-used count (carried over from an earlier calibration session's MAX click on a *different* stage/area), so the "no click needed" assumption silently confirmed whatever count was actually showing. A real sweep executed regardless (credits +180,000, confirmed screenshot, safe automatic return home), so nothing was lost, but the spend didn't match what was communicated to the user. **Fixed**: `_set_sweep_count` now always forces a known baseline via a new `_click_min_and_verify` (clicks `BOUNTY_SWEEP_MIN_BUTTON`, symmetric to the existing MAX click) before applying any `+` raises, for any count including 1 — never trusts an assumed default again.
|
||||||
|
|
||||||
|
With that fixed, the user approved spending the account's one remaining ticket on a fully-diagnostic run (polling and screenshotting every step of the result window instead of trusting the production code blindly). This surfaced a second, more serious near-miss: after the ticket count hit exactly 0, `_find_result_button`'s color search (still using a first-guess region copied from `EVENT_SWEEP_RESULT_BUTTON_REGION`) re-matched part of `BOUNTY_SWEEP_START_BUTTON`'s own real cyan pixels once the result dialog closed and the plain stage-info modal reappeared — clicking it re-triggered a **real Pyroxene (gem) ticket-purchase prompt**, the same shared gold-button dialog component as the AP tasks' insufficient-AP variant. The diagnostic loop's blind repeated clicks near this dialog never actually landed on its real "OK" button (confirmed: gem balance 10,494 unchanged before/after), but this was closer to an unintended real-currency spend than any previous task in this project has come. **Fixed three ways**: (1) `BOUNTY_SWEEP_RESULT_BUTTON_REGION` corrected from the first-guess copy to the real, pixel-scanned OK-button bbox (confirmed via offline analysis of the saved diagnostic screenshots — x 783–1139, y 879–991 — with zero further game interaction needed), now clear of `BOUNTY_SWEEP_START_BUTTON`'s own y-range (622–715); (2) a second, smaller contamination source found in the same offline analysis — the modal's own reward-icon artwork has a handful of pixels that incidentally fall inside the cyan color range even with no dialog showing (~878 stray pixels vs. the real button's ~34,000) — fixed via a new `min_pixels` parameter on `detector.find_color_centroid` (default 1, preserving existing callers' behavior unchanged) and `config.BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000`, comfortably separating real matches from noise; (3) `_watch_sweep_result` now has an explicit `_is_ticket_purchase_prompt` check as one of its `ends` conditions, so if this state is ever reached again for any reason, it's recognized and cancelled outright rather than left to a blind color search near a real-currency dialog. All three fixes were verified offline against the actual saved screenshots from the diagnostic run (the plain-modal and purchase-prompt states now correctly return no match; the real result dialog still matches correctly) before redeploying — no further ticket was available to re-test live same-day.
|
||||||
|
|
||||||
|
**Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
### OCR
|
### OCR
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user