"""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). **Position turned out not to be fixed** (found live 2026-08-01, see config.SHOP_PRICE_OCR_OFFSET's comment and script_error.md): Common Shop sinks a sold-out item to the bottom of its (scrollable) grid and shifts everything after it up, so a fixed (row, col) -> item mapping silently drifts the moment anything sells out. `select_targets_by_name` below replaces position-based identification with OCR'd item-name matching for Common Shop specifically -- genuinely closer to the reference's own item-identity-based (not position-based) design intent, just using a local OCR read instead of the reference's external price-list index, for the same "don't have that external table" reason the module docstring above already gives. Tactical Shop (`select_targets`, unchanged) has never shown this symptom -- one row, two items, no observed reordering -- so it stays on the simpler position-based path rather than migrating speculatively. """ 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 _name_rect(config, row, col): x, y = _checkbox_center(config, row, col) x1, y1, x2, y2 = config.SHOP_NAME_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 _is_purchasable(driver, config, row, col): """A sold-out/greyed buy button reads flat grey (B-R within +/-2 of 0, no color tint at all); a live/purchasable one carries a strong blue tint (B-R of +120 to +127) -- regardless of the button's own shimmer animation, which swings its raw brightness a lot but not its tint (see config.SHOP_BUTTON_PROBE_OFFSET's own comment for the live A/B that found this; an earlier brightness-only check was fooled by it).""" x, y = _checkbox_center(config, row, col) dx, dy = config.SHOP_BUTTON_PROBE_OFFSET r, g, b = driver.color_at(x + dx, y + dy) return (b - r) > config.SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT 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 select_targets_by_name(driver, config, targets): """Name-OCR-based sibling of select_targets, for grids where sold-out items sink to the bottom and shift everything else's position (see this module's own docstring and config.SHOP_PRICE_OCR_OFFSET's comment for the live incident this was built from). `targets` is a list of (name, expected_price) pairs, no row/col. Scans from the top of the (scrollable) grid, two rows per screenshot, OCR-reading each cell's item name. A cell is bought only if its name matches a not-yet-found target AND its price OCR matches that target's exact expected_price -- so an escalated repeat-purchase tier of the same item (confirmed live: exactly 2x price once the base tier sells out) is deliberately never matched, same "skip rather than overspend" call the user made for this project's other targets -- AND its buy button reads purchasable (not sold out). Scrolls by exactly one row (config.SHOP_SCROLL_STEP_CLICKS) between reads and stops once every target is found, the grid stops producing new content (real bottom), or config.SHOP_NAME_SCAN_MAX_STEPS is hit, whichever comes first. Matches by substring either direction rather than exact equality, same tolerance-for-a-stray-character margin story_sweep's own OCR text matching uses (`"掃討" in text`) -- a clean short UI label is usually exact, but this doesn't hard-fail on one stray/missing character. """ remaining = {name: price for name, price in targets} selected = [] n_rows = len(config.SHOP_ITEM_ROW_Y) n_cols = len(config.SHOP_ITEM_COL_X) prev_shot = None for _ in range(config.SHOP_NAME_SCAN_MAX_STEPS): if not remaining: break shot = driver.read_screenshot(detector.OCR_SHOT_PATH) if prev_shot is not None and (shot == prev_shot).all(): print("[shop] grid stopped scrolling -- reached the real bottom of the list") break prev_shot = shot for row in range(n_rows): if not remaining: break for col in range(n_cols): if not remaining: break name_text = detector.read_text(_name_rect(config, row, col), lang="jpn") match = next((n for n in remaining if n in name_text or (name_text and name_text in n)), None) if match is None: continue price = detector.read_int(_price_rect(config, row, col)) if price != remaining[match]: continue if not _is_purchasable(driver, config, row, col): 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] '{match}' checkbox did not register as checked -- skipping") continue selected.append((match, remaining.pop(match))) if remaining: driver.scroll(*config.SHOP_SCROLL_POINT, "down", clicks=config.SHOP_SCROLL_STEP_CLICKS) driver.wait(0.3) skipped = [] for name in remaining: print(f"[shop] '{name}' not found available at its expected price {remaining[name]} -- skipping (sold out this cycle, or catalog changed)") skipped.append((name, "not_available")) 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, select_fn=select_targets): """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. `select_fn` defaults to the position-based select_targets (Tactical Shop's own (row, col, name, price) targets); shop_common.py passes select_targets_by_name instead, for its (name, price) targets -- see that function's docstring for why Common Shop needs name-based identification specifically. """ 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(target[-1] for target 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_fn(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"