Refactor Common Shop item selection to use OCR-based name matching

- Updated `COMMON_SHOP_TARGETS` to use item names and expected prices instead of fixed grid positions.
- Introduced `SHOP_NAME_OCR_OFFSET` for accurate name recognition in the shop grid.
- Implemented `select_targets_by_name` function to handle item selection based on OCR'd names and prices, addressing issues with item position drift due to sold-out items.
- Enhanced the purchasable button check to rely on blue tint rather than brightness, ensuring accurate detection of available items.
- Adjusted `run_shop_tab` to utilize the new selection function for Common Shop tasks.
- Documented changes and rationale in `plan.md`, including live testing results confirming successful item purchases.
This commit is contained in:
Nik Afiq 2026-08-01 22:49:09 +09:00
parent 7f408c1ad1
commit 509fb27fd8
6 changed files with 215 additions and 30 deletions

View File

@ -701,9 +701,9 @@ Current project state: mailbox, cafe, stamina, story_sweep, story_sweep_hard, ev
- `ba_auto/tasks/story_sweep_hard.py` sweeps a fixed, user-supplied priority-ordered list of Hard-mode `(region, stage)` targets (`config.HARD_STORY_SWEEP_TARGETS`) via the in-game MAX button (capped at 3x by the game itself), reusing story_sweep.py's region-nav and stage-info-modal machinery directly (confirmed pixel-identical) but with its own fixed 3-row stage list (no scrolling/OCR-label search — Hard always has exactly missions 1-3) and its own campaign-active guard: it refuses to spend any AP unless the pink "キャンペーン中" reward-campaign banner is showing on the region-info card, unless run via the separate `story_sweep_hard_force` command
- a real gold-button money hazard exists in this same modal for Hard specifically: a stage that already used all 3 of today's auto-sweep clears still shows a clickable 入場 button, and attempting to sweep it raises a real "spend 40 Pyroxene/blue gems to refill today's clear count?" dialog, not just an AP-insufficient prompt — guarded by two independent layers (an OCR pre-check on the modal's own count field before ever clicking MAX, plus an OCR fallback on the dialog's own text, both declining via Escape rather than any positional click)
- see `plan.md` Phase 21 for the full live-testing writeup, including the navigation-cascade bug, the false-negative MAX-click abort bug, and the gem-refill hazard, all found and fixed against the real game
- `ba_auto/tasks/shop_common.py` and `ba_auto/tasks/shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets (`config.COMMON_SHOP_TARGETS` / `config.TACTICAL_SHOP_TARGETS`)
- item identification is by fixed grid position, not per-item OCR — the reference's own `get_item_position` indexes an external static price table this repo doesn't have, so a locally pixel-scanned position table is the faithful port, not an OCR-avoidance shortcut; price-digit OCR is layered on top as an extra catalog-drift safety check the reference doesn't even do per-item
- both shops were live-tested with real purchases (see `plan.md` Phase 11), which also surfaced a real, previously-unknown per-refresh-cycle purchase cap on these items (not shown as a visible counter) — the task correctly detected the now-unselectable items and safely declined rather than misfiring
- `ba_auto/tasks/shop_common.py` and `ba_auto/tasks/shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`); Tactical Shop still targets fixed `(row, col, name, expected_price)` positions (`config.TACTICAL_SHOP_TARGETS`) since its one-row, two-item grid has never been observed to reorder
- Common Shop (`config.COMMON_SHOP_TARGETS`, now `(name, expected_price)` pairs) originally used the same fixed-position design — the reference's own `get_item_position` indexes an external static price table this repo doesn't have, so a locally pixel-scanned position table was the faithful port, not an OCR-avoidance shortcut — but **that broke live** (2026-08-01): this shop sinks sold-out items to the bottom of its (scrollable, ~24-slot) grid and shifts everything after them up, silently invalidating any fixed `(row, col)` mapping the moment anything sells out. Fixed with `shop_utils.select_targets_by_name`, which scans/scrolls the grid and identifies each target by OCR'd item name instead, verified against an exact price match (so an item's escalated repeat-purchase-tier price, confirmed live at exactly 2x its base price once that sells out, is deliberately never matched) plus a purchasable-button color check. See `plan.md` Phase 25 follow-up #2 for the full incident, including a second live bug in the purchasable check itself — the buy button's fill genuinely shimmers/animates, so a brightness-based check was fooled; fixed by checking blue color *tint* (`B-R`) instead, which stays constant across the animation
- both shops were live-tested with real purchases (see `plan.md` Phase 11), which also surfaced a real, previously-unknown per-refresh-cycle purchase cap on these items (not shown as a visible counter) — the task correctly detected the now-unselectable items and safely declined rather than misfiring; Common Shop's name-based rewrite above was re-verified live post-fix (real purchase of the one item actually available, others correctly logged as unavailable rather than misdiagnosed)
- `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid (a scrollable list of 12 named regions, each opening a grid modal of up to 9 location cards), picking the highest-affection available lesson each time until lesson tickets or lessons run out
- per-cell affection is read via `detector.read_int_on_heart_badge`, a dedicated OCR path for the pink/magenta heart-shaped badge — the project's normal grayscale-threshold OCR (`read_int`) misreads it, because the badge's own outline stroke survives the same threshold as the digit glyph; a "done today" portrait keeps its number and gets a green checkmark added alongside it rather than losing the number, so done-ness is checked via that checkmark's color, not inferred from a failed OCR read
- lesson was live-tested with real tickets spent (see `plan.md` Phase 12), which surfaced two real bugs from that assumption gap plus an OCR contamination issue — both fixed; see Phase 12 for the full writeup

View File

@ -772,19 +772,61 @@ SHOP_CHECKED_RGB = ((60, 130, 80), (220, 245, 115))
# for the largest configured price without bleeding into the neighboring
# column's card.
SHOP_PRICE_OCR_OFFSET = (48, 166, 145, 195)
# A real live run (script_error.md, 2026-07-31) misread '中級レポート's
# price as 1250060 instead of 125000 -- a stray extra digit, not a
# systematically wrong crop (every other target, including three other
# 6-digit prices in this same list, read correctly in that same run, and
# re-summing the run's own "bought N item(s) for TOTAL credits" log against
# every OTHER target's price confirms it). One-off tesseract flake, same
# class of transient misread this project already retries elsewhere (see
# driver.read_screenshot's decode-retry doc) rather than a calibration
# problem -- so shop_utils.select_targets re-reads the price up to this
# many times, taking the first read that matches expected_price, before
# concluding the catalog actually changed.
# A real live run (script_error.md, 2026-07-31) misread '中級レポート's price
# as 1250060 instead of 125000, and this recurred identically every day for
# several days afterward -- initially (mis)diagnosed as a one-off tesseract
# flake and "fixed" with the retry loop below, but the real cause (found by
# live screenshot inspection, 2026-08-01) is that this shop sinks any
# sold-out item to the bottom of the grid and shifts everything after it
# up by one slot -- '初級レポート' (originally col 0) sold out and sank,
# sliding '中級レポート' from col 1 into col 0, so the fixed-position config
# was reading whatever backfilled col 1 instead. Retries alone can't fix a
# deterministically-wrong position, only a genuinely flaky single read --
# kept for that narrower case (and still used by the position-based
# select_targets, i.e. the Tactical Shop path below), but Common Shop's own
# targets are now matched by OCR'd item name instead (see
# shop_utils.select_targets_by_name), immune to this reordering.
SHOP_PRICE_OCR_RETRIES = 3
# Item-name-text crop, as an (x1, y1, x2, y2) offset added to a cell's own
# (col_x, row_y) -- used by shop_utils.select_targets_by_name to identify
# an item by its name rather than assuming a fixed grid position (see
# SHOP_PRICE_OCR_RETRIES's comment above for why position alone isn't
# reliable). Pixel-scanned and OCR-tested live against every current
# Common Shop target name (single-line names only -- two-line-wrapping
# names like the bundle/material items sit differently and aren't covered,
# but none of the configured targets below wrap).
SHOP_NAME_OCR_OFFSET = (-75, -105, 160, -45)
# A point inside each cell's own buy-button bar. First calibrated against
# two downloaded screenshots as a plain brightness check (purchasable
# bright ~(235,241,241) vs. sold-out/greyed ~172-174 flat) -- but a live
# same-session A/B (2026-08-01, after the button-color check found nothing
# purchasable on its first real run) showed the button's fill genuinely
# shimmers/animates: the SAME real purchasable button read anywhere from a
# dark navy (45,70,99) to a bright cyan (126,222,253) across different
# moments, sometimes dimmer than the sold-out reference. What stays
# constant regardless of animation phase is the blue TINT, not brightness:
# purchasable consistently reads B-R of +120 to +127 (any phase sampled),
# sold-out/greyed consistently reads B-R within +/-2 (flat R==G==B, no
# tint at all, confirmed across all 8 grid positions in two separate
# screenshots). SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT is the (B-R)
# threshold, set well clear of both clusters.
SHOP_BUTTON_PROBE_OFFSET = (90, 210)
SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT = 40
# One driver.scroll(..., clicks=N) call at this many clicks reliably
# advances the grid by exactly one row (confirmed live: the item at
# row_y[1] before the scroll reappears at row_y[0] after it, same name and
# price) -- so select_targets_by_name can scan two full rows per
# screenshot and scroll by exactly one row's worth between reads, without
# skipping or double-reading a row.
SHOP_SCROLL_STEP_CLICKS = 3
SHOP_SCROLL_POINT = (1310, 485)
# Hard cap on scroll steps for select_targets_by_name, independent of its
# own "screenshot unchanged after scrolling" bottom-of-list detection --
# 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
SHOP_BUY_BUTTON = (1751, 1112)
SHOP_CANCEL_BUTTON = (1525, 1112)
# A corner point that both the purchase-confirm dialog and the post-purchase
@ -809,19 +851,27 @@ CREDIT_BALANCE_OCR_RECT = (1040, 15, 1290, 55)
# the item grid on that tab specifically (not in the top status bar).
TACTICAL_COIN_OCR_RECT = (1090, 100, 1290, 150)
# (row, col, item name (log/debug only -- identification is by grid
# position, see shop_utils.py's module docstring for why), expected
# credit-point price). Buy-list confirmed with the user; all 8 are visible
# without scrolling.
# (item name, expected credit-point price) -- matched by OCR'd name
# wherever it currently sits in the (scrollable) grid, not by fixed
# position; see SHOP_PRICE_OCR_OFFSET's comment above for why position
# alone broke. Buy-list confirmed with the user, 2026-08-01, after the
# catalog turned out to have grown well beyond these original 8 items
# (bundle packs, crafting-material items) -- those new items were
# deliberately left out of auto-buy for now, user's own call. Each of the
# 4 "強化珠" tiers also now has a second, escalated-price repeat-purchase
# slot once its base slot sells out for the cycle (confirmed live: exactly
# 2x the price below) -- select_targets_by_name only ever matches the
# exact price below, so it deliberately never chases that escalated tier,
# also the user's own call (skip skip, don't overspend chasing it).
COMMON_SHOP_TARGETS = [
(0, 0, "初級レポート", 12500),
(0, 1, "中級レポート", 125000),
(0, 2, "上級レポート", 300000),
(0, 3, "最上級レポート", 500000),
(1, 0, "初級強化珠", 10000),
(1, 1, "中級強化珠", 40000),
(1, 2, "上級強化珠", 96000),
(1, 3, "最上級強化珠", 128000),
("初級レポート", 12500),
("中級レポート", 125000),
("上級レポート", 300000),
("最上級レポート", 500000),
("初級強化珠", 10000),
("中級強化珠", 40000),
("上級強化珠", 96000),
("最上級強化珠", 128000),
]
# (row, col, item name, expected tactical-coin price). Buy-list confirmed

File diff suppressed because one or more lines are too long

View File

@ -20,6 +20,7 @@ def run(driver, config):
targets=config.COMMON_SHOP_TARGETS,
balance_rect=config.CREDIT_BALANCE_OCR_RECT,
currency_label="credits",
select_fn=shop_utils.select_targets_by_name,
)
# navigation.return_to_home over a bare click -- verified + carries the

View File

@ -13,6 +13,20 @@ 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
@ -32,11 +46,30 @@ def _price_rect(config, row, col):
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
@ -76,6 +109,75 @@ def select_targets(driver, config, targets):
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
@ -97,12 +199,18 @@ def confirm_purchase(driver, config):
return False
def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_label):
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")
@ -119,12 +227,12 @@ def run_shop_tab(driver, config, *, tab_button, targets, balance_rect, currency_
print(f"[shop] could not read {currency_label} balance, aborting without spending")
return "balance_unreadable"
total_cost = sum(price for _, _, _, price in targets)
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_targets(driver, config, targets)
selected, skipped = select_fn(driver, config, targets)
if not selected:
print("[shop] nothing selected (all targets skipped), cancelling")
return "nothing_selected"

26
plan.md
View File

@ -1090,6 +1090,32 @@ Pure reclaim (no AP/credit/ticket spend, no choice to make) -- same category as
**Status: Done.** Both claim paths (mission points, pass level) confirmed live with real reward claims during calibration; the actual `./ba_dailies.sh battle_pass` CLI dispatch itself confirmed live across two real runs (the second, post-fix, run clean).
### Phase 25 follow-up: silent stuck-on-pass-menu bug found from a real `daily` preset run, plus a shop price-OCR flake (2026-07-31)
Both bugs were reported together via `script_error.md` from a real `daily` cron/preset run, debugged and fixed without live-testing (not yet re-confirmed live).
**battle_pass left the game stuck off-home with zero warning, breaking every task after it in the same run.** The log showed `[battle_pass] claiming reward` twice then `[battle_pass] Done.`, immediately followed by `event_sweep` failing "event screen not detected after click" three times in a row with no warning printed in between -- meaning battle_pass's own exit loop AND `ba_daily.py`'s generic post-task `navigation.return_to_home` cleanup both silently agreed the game was home when it almost certainly wasn't. Root cause: the exit loop's only termination check, `_inside_battle_pass(driver, config)` going False, is a NEGATIVE condition (proves the pass-menu's own dark footer band is gone) with no confirmation that true home was actually reached, and it had no failure print at all if it ran out of `EXIT_MAX_ROUNDS`. The generic fallback can't help either, since (per this phase's own writeup above) `navigation.is_on_subscreen`/`is_modal_open` both read false/false on battle pass screens same as home. Leading suspect for what actually happened: a level-up bundling more than one reward item into separate stacked "報酬獲得!" cards, which `_claim_all`'s single hardcoded extra dismiss Enter wouldn't fully clear -- a leftover card covering the footer probe just enough to misread as "left", and also covering the claim button enough to make the NEXT round's `_claim_enabled` check misread "still blocked" as "nothing left to claim."
Fixed two ways:
- Promoted login.py's `_home_nav_bar_visible` (the bottom nav bar's own flat near-white band, the only POSITIVE "genuinely home" signal in this project, built for the identical class of false-positive during Phase 18) to `navigation.is_home_nav_bar_visible`/`HOME_NAV_BAR_*`, shared rather than duplicated. battle_pass's exit loop (`_exit_to_home`/`_confirmed_home`) now requires both `_inside_battle_pass` False AND `is_home_nav_bar_visible` True before stopping, and prints a clear warning if it still can't confirm after `EXIT_MAX_ROUNDS` -- a stuck run now shows up in logs instead of masquerading as "Done." login.py itself was only refactored to call the shared function (identical logic, no behavior change) to avoid leaving two copies.
- `_claim_all`'s dismiss step now presses the extra dismiss Enter `DISMISS_EXTRA_PRESSES` (2) times instead of 1, still the same "harmless no-op if nothing is showing" convention as before, just wider -- speculative (the multi-card theory isn't directly confirmed) but low-risk and directly targets the leading suspect.
**Not yet re-verified live** -- would need a real level-up (or another naturally-occurring stuck state) to confirm the fix actually self-heals, not just that it fails loudly instead of silently. Next `battle_pass`/`daily` run worth watching for the new warning line specifically.
**Shop price OCR misread one item's price by a stray digit** (`'中級レポート' price read as 1250060, expected 125000`), skipping a real, affordable purchase. Confirmed a one-off flake, not a systematic crop problem: re-summing the same run's own "bought 7 item(s) for 1086500 credits, skipped 1" log against `COMMON_SHOP_TARGETS`' real prices shows every OTHER target -- including three other 6-digit prices in the same list -- read correctly in that same run. `shop_utils.select_targets` previously read the price exactly once per target with no retry, unlike this project's usual "retry a transient bad frame, don't give up on one read" convention (e.g. `driver.read_screenshot`'s decode retry). Fixed: re-reads the price up to `config.SHOP_PRICE_OCR_RETRIES` (3) times, taking the first read that matches `expected_price`; a genuine catalog change still reads the same (wrong) value on every retry and gets skipped as before. Not yet re-verified live (would need a real purchase run to confirm the previously-skipped item now buys correctly).
### Phase 25 follow-up #2: the shop "price OCR flake" above was misdiagnosed -- real cause was position drift, fixed with OCR name-matching (2026-08-01)
The retry fix above shipped, but the user reported the exact same `'中級レポート'` skip recurring identically every day afterward -- not a flake at all, since retries can't fix a deterministically-wrong read. Opened Common Shop live and screenshotted the grid: `中級レポート` had moved from its configured col 1 into col 0. Root cause, confirmed by scrolling through the full (now ~6-row/24-slot, much bigger than the original 8-item design assumed) catalog: this shop sinks any sold-out item to the bottom of its list and shifts everything after it up a slot, so a fixed `(row, col)` -> item mapping silently drifts the moment anything sells out -- `初級レポート` (originally col 0) had sold out and sunk, sliding `中級レポート` into its place, while `COMMON_SHOP_TARGETS` still pointed at col 1.
Also newly discovered live: each of the 4 `強化珠` (enhancement bead) tiers gets a second, escalated-price repeat-purchase slot (confirmed exactly 2x) once its base-price slot sells out for the cycle, and the catalog now includes 4 bundle packs and several crafting-material items (マンドレイクの種, アンティキティラ装置の欠片, ヴォイニッチ手稿のコピー variants, 壊れたニムルドレンズ) not in the original design at all. User's call, 2026-08-01: leave the new bundle/material items out of auto-buy for now, and never chase the escalated repeat-purchase tier (only ever match the original expected_price).
Fixed with `shop_utils.select_targets_by_name`, a name-OCR-based sibling of `select_targets` used by `shop_common.py` only (`shop_tactical.py` stays on the position-based path -- one row, two items, never observed reordering). Scans two grid rows per screenshot, OCR-reading each cell's item name (`config.SHOP_NAME_OCR_OFFSET`), matching against not-yet-found targets by substring either direction (same tolerance-for-a-stray-character margin as story_sweep's own OCR text matching), then verifying BOTH exact price match (so the escalated tier is never touched) AND a purchasable buy-button check before clicking. Scrolls by exactly one row between reads (`config.SHOP_SCROLL_STEP_CLICKS`, confirmed live) and stops once every target is found or the grid stops producing new content.
Building the purchasable check itself hit a real live bug worth remembering: an initial brightness-based check (calibrated from two downloaded screenshots, purchasable ~(235,241,241) vs. sold-out flat grey ~172-174) found NOTHING purchasable on the very first real run, including the just-confirmed-available `中級レポート`. A same-session live A/B (fresh screenshot vs. a scrolled-down sold-out row, same session) showed why: the buy button's fill genuinely shimmers/animates, reading anywhere from a dark navy `(45,70,99)` to a bright cyan `(126,222,253)` moment to moment -- sometimes dimmer than the sold-out reference itself, so brightness alone can't tell them apart. What stays constant regardless of animation phase is blue *tint*, not brightness: purchasable consistently reads `B-R` of +120 to +127 at every phase sampled, sold-out consistently reads `B-R` within +/-2 (flat, no tint). Switched `_is_purchasable` to a `B-R` threshold (`config.SHOP_BUTTON_PURCHASABLE_MIN_BLUE_TINT`, 40) instead.
**Live-tested successfully**: real run bought `中級レポート` for 125,000 credits (the exact item that had been failing for days) and correctly logged the other 7 targets as unavailable this cycle rather than misdiagnosing them as a catalog change -- verified against the shop screen directly afterward (item gone from its slot, credit balance down exactly 125,000) and confirmed the task still returned cleanly to home.
## Prerequisites
### OCR