feat: enhance shop item selection logic to prevent under-purchases and improve name matching
This commit is contained in:
parent
0ff3dca0b9
commit
6d37e889eb
@ -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
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user