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
|
# 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.
|
# leaves headroom without scrolling forever if that detection ever misses.
|
||||||
SHOP_NAME_SCAN_MAX_STEPS = 12
|
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_BUY_BUTTON = (1751, 1112)
|
||||||
SHOP_CANCEL_BUTTON = (1525, 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
|
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):
|
def select_targets_by_name(driver, config, targets):
|
||||||
"""Name-OCR-based sibling of select_targets, for grids where sold-out
|
"""Name-OCR-based sibling of select_targets, for grids where a bought
|
||||||
items sink to the bottom and shift everything else's position (see this
|
(or sold-out) item sinks to the bottom and shifts everything else's
|
||||||
module's own docstring and config.SHOP_PRICE_OCR_OFFSET's comment for
|
position (see this module's own docstring and config.SHOP_PRICE_OCR_OFFSET's
|
||||||
the live incident this was built from). `targets` is a list of (name,
|
comment for the live incident this was built from). `targets` is a list
|
||||||
expected_price) pairs, no row/col.
|
of (name, expected_price) pairs, no row/col.
|
||||||
|
|
||||||
Scans from the top of the (scrollable) grid, two rows per screenshot,
|
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
|
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
|
tolerance-for-a-stray-character margin story_sweep's own OCR text
|
||||||
matching uses (`"掃討" in text`) -- a clean short UI label is usually
|
matching uses (`"掃討" in text`) -- a clean short UI label is usually
|
||||||
exact, but this doesn't hard-fail on one stray/missing character.
|
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}
|
remaining = {name: price for name, price in targets}
|
||||||
selected = []
|
selected = []
|
||||||
n_rows = len(config.SHOP_ITEM_ROW_Y)
|
|
||||||
n_cols = len(config.SHOP_ITEM_COL_X)
|
|
||||||
prev_shot = None
|
prev_shot = None
|
||||||
for _ in range(config.SHOP_NAME_SCAN_MAX_STEPS):
|
for _ in range(config.SHOP_NAME_SCAN_MAX_STEPS):
|
||||||
if not remaining:
|
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")
|
print("[shop] grid stopped scrolling -- reached the real bottom of the list")
|
||||||
break
|
break
|
||||||
prev_shot = shot
|
prev_shot = shot
|
||||||
for row in range(n_rows):
|
|
||||||
if not remaining:
|
while remaining:
|
||||||
break
|
match = _scan_current_view(driver, config, remaining)
|
||||||
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:
|
if match is None:
|
||||||
continue
|
break
|
||||||
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)))
|
selected.append((match, remaining.pop(match)))
|
||||||
|
|
||||||
if remaining:
|
if remaining:
|
||||||
driver.scroll(*config.SHOP_SCROLL_POINT, "down", clicks=config.SHOP_SCROLL_STEP_CLICKS)
|
driver.scroll(*config.SHOP_SCROLL_POINT, "down", clicks=config.SHOP_SCROLL_STEP_CLICKS)
|
||||||
driver.wait(0.3)
|
driver.wait(0.3)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user