feat: enhance shop item selection logic to prevent under-purchases and improve name matching

This commit is contained in:
Nik Afiq 2026-09-17 11:22:08 +09:00
parent 0ff3dca0b9
commit 6d37e889eb
3 changed files with 120 additions and 29 deletions

View File

@ -846,6 +846,18 @@ SHOP_SCROLL_POINT = (1310, 485)
# the live catalog was ~6 rows deep (24 slots) when this was built, so this
# leaves headroom without scrolling forever if that detection ever misses.
SHOP_NAME_SCAN_MAX_STEPS = 12
# Extra wait after a confirmed checkbox click, before select_targets_by_name
# rescans the grid for its next target -- on top of the 0.3s already spent
# confirming the checkbox itself registered. Added 2026-09-17: a real live
# run still missed two targets confirmed available and unscrolled minutes
# later, even after the same-pass rescan fix -- the checkbox's own
# "checked" confirmation settles faster than the rest of the grid's
# reflow/collapse animation, so scanning again too soon can OCR a
# still-mid-transition frame. Not yet tuned to a minimum value -- 1.5s
# chosen to comfortably clear a real UI transition (matches this project's
# usual tab-switch wait elsewhere, e.g. gem_shop.py's own 1.5s subtab
# waits), not calibrated down from a measured animation duration.
SHOP_REFLOW_SETTLE_WAIT = 1.5
SHOP_BUY_BUTTON = (1751, 1112)
SHOP_CANCEL_BUTTON = (1525, 1112)

File diff suppressed because one or more lines are too long

View File

@ -109,12 +109,88 @@ def select_targets(driver, config, targets):
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 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.
"""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
@ -132,11 +208,29 @@ def select_targets_by_name(driver, config, targets):
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 = []
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:
@ -146,28 +240,13 @@ def select_targets_by_name(driver, config, targets):
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)
while remaining:
match = _scan_current_view(driver, config, 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
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)