329 lines
16 KiB
Python
329 lines
16 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).
|
|
|
|
**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 _match_name(name_text, remaining):
|
|
"""Resolve an OCR'd cell name to the single `remaining` target it
|
|
refers to, preferring an exact match over a substring one.
|
|
|
|
Real bug (2026-09-17): Common Shop's own tier-naming convention nests
|
|
a shorter tier name inside a longer one -- "上級強化珠" (Advanced) is
|
|
literally a substring of "最上級強化珠" (Highest), same for
|
|
"上級レポート"/"最上級レポート". The original substring-only match
|
|
(`next(n for n in remaining if n in name_text or name_text in n)`) had
|
|
no preference for an exact match, so scanning a "最上級強化珠" card
|
|
while "上級強化珠" was still also in `remaining` could resolve to the
|
|
WRONG (shorter) target purely because it happened to iterate first in
|
|
dict order -- the subsequent price check then compared against the
|
|
wrong target's price, always mismatched, and permanently skipped the
|
|
real card every single run. Confirmed live: 最上級強化珠 sat available
|
|
at its exact listed price for hours across three separate real runs,
|
|
never once bought, while shorter-named siblings bought fine.
|
|
|
|
An exact match is checked first and always wins outright. The
|
|
substring fallback (for a genuinely imperfect OCR read missing/adding
|
|
a stray character) still exists, but picks the LONGEST candidate --
|
|
the more specific name -- to minimize the same class of collision.
|
|
"""
|
|
if not name_text:
|
|
return None
|
|
if name_text in remaining:
|
|
return name_text
|
|
candidates = [n for n in remaining if n in name_text or name_text in n]
|
|
if not candidates:
|
|
return None
|
|
return max(candidates, key=len)
|
|
|
|
|
|
def _scan_current_view(driver, config, remaining):
|
|
"""One pass over the currently-visible grid (no scrolling), buying at
|
|
most one still-wanted target and returning its name, or None if nothing
|
|
in `remaining` matched anywhere on screen right now.
|
|
|
|
Deliberately stops and returns after the FIRST successful buy rather
|
|
than continuing the row/col loop -- see select_targets_by_name's
|
|
docstring for why: buying can reflow the grid mid-pass, and continuing
|
|
with the old loop's positions after that happens is exactly what caused
|
|
a real live under-purchase (2026-09-17).
|
|
"""
|
|
n_rows = len(config.SHOP_ITEM_ROW_Y)
|
|
n_cols = len(config.SHOP_ITEM_COL_X)
|
|
for row in range(n_rows):
|
|
for col in range(n_cols):
|
|
name_text = detector.read_text(_name_rect(config, row, col), lang="jpn")
|
|
match = _match_name(name_text, remaining)
|
|
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
|
|
# Extra settle time before the caller's next rescan -- a real
|
|
# live run (2026-09-17) still missed two further targets that
|
|
# were confirmed sitting available minutes later, even with the
|
|
# same-pass reflow fix above; the checkbox itself registers
|
|
# "checked" well before the grid's own reflow/collapse animation
|
|
# visually finishes resettling every other card's position, so
|
|
# an immediate rescan can OCR a still-mid-transition frame and
|
|
# misread a genuinely-present target as absent.
|
|
driver.wait(config.SHOP_REFLOW_SETTLE_WAIT)
|
|
return match
|
|
return None
|
|
|
|
|
|
def select_targets_by_name(driver, config, targets):
|
|
"""Name-OCR-based sibling of select_targets, for grids where a bought
|
|
(or sold-out) item sinks to the bottom and shifts 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.
|
|
|
|
**Real live under-purchase, 2026-09-17**: a real cron run bought only 4
|
|
of 8 configured targets, logging the other 4 as "not found available" --
|
|
but hours later, with nothing else having touched the account, those
|
|
exact 4 items were sitting unbought at their exact expected prices right
|
|
at the top of the (unscrolled) grid. Root-caused (with the user's own
|
|
pasted log as the key evidence, cross-checked against the successfully
|
|
bought items' prices summing exactly to the run's own logged total
|
|
spend) to a same-pass reflow bug: the OLD version of this function kept
|
|
iterating its row/col loop with the SAME screenshot-implied positions
|
|
after a successful buy, even though buying an item collapses its grid
|
|
slot and shifts every later item forward -- so a still-wanted item that
|
|
got shifted into an already-visited position was silently skipped for
|
|
that pass, and the outer loop's unconditional forward-only scroll then
|
|
moved the viewport away from it, orphaning it for the rest of the run.
|
|
Fixed by re-scanning the CURRENT (unscrolled) view from scratch via
|
|
`_scan_current_view` after every single successful buy -- each call
|
|
re-reads every cell fresh, so a reflow can't leave a stale match
|
|
unnoticed. Scrolling only happens once a full pass finds nothing left
|
|
to buy on the current screen.
|
|
"""
|
|
remaining = {name: price for name, price in targets}
|
|
selected = []
|
|
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
|
|
|
|
while remaining:
|
|
match = _scan_current_view(driver, config, remaining)
|
|
if match is None:
|
|
break
|
|
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"
|