ba-auto-daily/ba_auto/tasks/shop_utils.py

142 lines
6.2 KiB
Python

"""Shared Common Shop / Tactical Shop grid logic.
Ported from baas-reference module/shop/shop_utils.py's get_item_position/
ensure_choose/buy pattern -- the checkbox-select-then-bulk-buy grid shared by
both shop tabs in the live client. Adapted from the reference's
color+template item-state scan to this project's fixed-grid-position +
price-OCR-verify design: unlike story_sweep, the reference's own
item-identification here isn't OCR at all -- it indexes into
self.static_config.common_shop_price_list, a table sourced from an external
resource this repo doesn't have. So a locally pixel-scanned, name-commented
position table (config.COMMON_SHOP_TARGETS / config.TACTICAL_SHOP_TARGETS) is
the faithful local equivalent, not a shortcut around OCR the reference uses.
Price-digit OCR -- something the reference doesn't even do per-item -- is
layered on top as an extra safety net against catalog drift, consistent with
this project's verify-before-spend pattern elsewhere (mailbox/cafe/story_sweep).
"""
from ba_auto import detector, navigation
def _checkbox_center(config, row, col):
return config.SHOP_ITEM_COL_X[col], config.SHOP_ITEM_ROW_Y[row]
def _checkbox_region(config, row, col):
x, y = _checkbox_center(config, row, col)
return (x - 15, y - 15, x + 15, y + 15)
def _price_rect(config, row, col):
x, y = _checkbox_center(config, row, col)
x1, y1, x2, y2 = config.SHOP_PRICE_OCR_OFFSET
return (x + x1, y + y1, x + x2, y + y2)
def _is_checked(driver, config, row, col):
lo, hi = config.SHOP_CHECKED_RGB
return detector.region_contains_color(_checkbox_region(config, row, col), lo, hi)
def select_targets(driver, config, targets):
"""OCR-verify each target's price, then click its checkbox and confirm
it registers as checked. Returns (selected, skipped); a price mismatch
or an unconfirmed checkbox skips just that target rather than aborting
the whole run (mirrors story_sweep's per-target skip-not-abort design).
Re-reads the price up to config.SHOP_PRICE_OCR_RETRIES times, stopping
early on the first read that matches expected_price -- a real live run
misread one target's price by a single stray digit while every other
target (including other 6-digit prices) read correctly in the same run,
so a single bad frame shouldn't skip a real purchase. A genuine catalog
change still reads the same (wrong) value across every retry and gets
skipped as before. See config.SHOP_PRICE_OCR_RETRIES's own comment.
"""
selected = []
skipped = []
for row, col, name, expected_price in targets:
price = None
for attempt in range(1, config.SHOP_PRICE_OCR_RETRIES + 1):
price = detector.read_int(_price_rect(config, row, col))
if price == expected_price:
break
if attempt < config.SHOP_PRICE_OCR_RETRIES:
driver.wait(0.3)
if price != expected_price:
print(f"[shop] '{name}' price read as {price}, expected {expected_price} -- skipping (catalog may have changed)")
skipped.append((name, "price_mismatch"))
continue
x, y = _checkbox_center(config, row, col)
driver.click(x, y)
driver.wait(0.3)
if not _is_checked(driver, config, row, col):
print(f"[shop] '{name}' checkbox did not register as checked -- skipping")
skipped.append((name, "checkbox_not_confirmed"))
continue
selected.append((name, expected_price))
return selected, skipped
def confirm_purchase(driver, config):
"""Click the bulk Buy button, then click through the purchase-confirm
dialog and the reward-acquired banner. Both dim
config.SHOP_OVERLAY_PROBE away from pure white; press Enter until it
reads idle again, bounded by config.SHOP_PURCHASE_MAX_ENTER_PRESSES --
this project doesn't press keys past a bound against an unrecognized
state (see navigation.wait_for_state). Returns True once idle, False if
it never clears within the bound.
"""
driver.click(*config.SHOP_BUY_BUTTON)
driver.wait(1.5)
min_ch = config.SHOP_OVERLAY_IDLE_MIN_CHANNEL
for _ in range(config.SHOP_PURCHASE_MAX_ENTER_PRESSES):
r, g, b = driver.color_at(*config.SHOP_OVERLAY_PROBE)
if r > min_ch and g > min_ch and b > min_ch:
return True
driver.keypress("Return")
driver.wait(1.5)
return False
def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_label):
"""Shared control flow for both shop tabs: open the tab, verify enough
currency for everything configured, select+buy, report the outcome.
Ported from the reference's common_shop.py/tactical_challenge_shop.py
implement() -- both are this same shape (read assets, calculate cost,
buy, verify), differing only in tab entry point and currency.
"""
if not targets:
print(f"[shop] no targets configured for {currency_label}, nothing to do")
return "no_targets"
driver.click(*tab_button)
driver.wait(1.5)
if not navigation.is_on_subscreen(driver):
print("[shop] could not confirm shop tab opened, aborting without pressing further keys")
return "navigation_failed"
balance = detector.read_int(balance_rect)
if balance is None:
print(f"[shop] could not read {currency_label} balance, aborting without spending")
return "balance_unreadable"
total_cost = sum(price for _, _, _, price in targets)
if balance < total_cost:
print(f"[shop] insufficient {currency_label}: have {balance}, need {total_cost} -- stopping, nothing bought")
return "inadequate_assets"
selected, skipped = select_targets(driver, config, targets)
if not selected:
print("[shop] nothing selected (all targets skipped), cancelling")
return "nothing_selected"
if not confirm_purchase(driver, config):
print("[shop] purchase confirmation did not resolve to an idle screen within the retry bound -- stopping, check the game manually")
return "unrecognized_state"
spent = sum(price for _, price in selected)
msg = f"[shop] bought {len(selected)} item(s) for {spent} {currency_label}"
if skipped:
msg += f", skipped {len(skipped)}"
print(msg)
return "purchased"