From 6d37e889eb5df1597e270cf8579fb2d2b0c0250e Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 17 Sep 2026 11:22:08 +0900 Subject: [PATCH] feat: enhance shop item selection logic to prevent under-purchases and improve name matching --- ba_auto/config.py | 12 +++ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/shop_utils.py | 135 +++++++++++++++++++++++------ 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/ba_auto/config.py b/ba_auto/config.py index d6918e8..d28126e 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -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) diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index 462ffa2..d65890e 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -17,7 +17,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Commissions | `module/clear_special_task_power.py` | Need to inspect | `ba_auto/tasks/commission.py` | sweep/color adaptation | Not started | | Exit game | None -- searched `~/repo/baas-reference/` thoroughly, no counterpart exists. The closest match, `core/Baas_thread.py`'s `shutdown()`/`start_shutdown()` (line ~984), is an optional full Windows OS shutdown (`subprocess.run(["shutdown", "-s", "-t", "60"])`) gated by a user toggle, not a game-client exit -- out of scope (this project's game and X display run on `nik-gpu`, shutting the host down would kill the SSH session too, nothing like what was requested). The only other app-closing call, `connection.py`'s `close_current_app` (line 381, plain ADB `app_stop`), exists purely for `Baas_thread.py`'s own error-recovery restarts (`deal_with_package_incorrect`/`deal_with_func_call_timeout`) inside a persistent background scheduler that's designed to keep running indefinitely -- it never deliberately exits once daily tasks finish. This feature is new, desktop-specific convenience logic motivated by this project's different architecture (cron launches a fresh one-shot process per preset, so closing the game after a run has real value the reference's always-on scheduler never needed), not a port of anything. | `ba_auto/tasks/exit_game.py` | No new driver/detector primitives needed -- reuses `navigation.return_to_home` (must-be-true-home gate), `navigation.is_modal_open` (verifies the Escape-triggered "exit the game?" dialog actually opened, the same generic dim-probe reused across bounty/gem_shop/circle's own confirm dialogs), `driver.keypress("Escape"/"Return")`, and `driver.window_exists()` (verifies the game process/window is actually gone afterward, not just that Enter was sent) | Done. `navigation.return_to_home`'s own docstring already documents the mechanism this relies on: an Escape press on the confirmed true home screen (nowhere else) raises this exact dialog -- every other task treats that as a hazard to avoid triggering by accident; this is the one task that wants it, gated the same way (only fires from a verified true-home state). No force-kill fallback if the graceful Escape/Enter path doesn't verify -- matches this project's established "abort cleanly on unknown state rather than guess" convention (gem_shop/bounty), rather than reaching for `driver.kill_game()`. Opt-in only, appended to the end of the `daily`/`q4h` presets (not `DEFAULT_ORDER`), since it deliberately ends the session. **Confirmed live (2026-07-18)** via a standalone `./ba_dailies.sh exit_game` run, user-reported "works well" -- `is_modal_open`'s generic dim-probe correctly read the exit-confirmation dialog after Escape, and the game closed cleanly after Enter. Not yet exercised as the tail end of a full `daily`/`q4h` preset run (only standalone so far), though nothing in its own logic depends on which task ran before it. | | Arena | `module/arena.py` | `implement` (main flow), `to_tactical_challenge` (nav from main page), `get_tickets` (ticket-count OCR), `choose_enemy` (self/opponent level OCR + bounded refresh-reroll loop), `check_skip_button` (skip-toggle color probe), `fight` (click fight, wait for win/lose), `collect_tactical_challenge_reward` (two reward-slot color probes) | `ba_auto/tasks/arena.py` | Ticket count/self level/opponent level/rank are plain digit OCR (`detector.read_int`/`read_text`, plus a new `read_int_white_on_dark` for the profile card's bright-on-dark level text). Skip-toggle state and the two reward-slot claimed-vs-claimable colors are plain pixel-color probes (`driver.color_at`/`_color_in_range`). The post-fight WIN/LOSE result modal and an unrelated list-refresh-expired notice are NOT detected by precisely locating a button (a color-region search proved unreliable — see Status); they're dismissed via a bounded blind-Enter-press loop matching `lesson.py`'s own `_run_one_schedule` pattern, gated by a hard safety check against the one modal where Enter is dangerous (opponent-info's own attack-formation button, checked via its fixed-position gold button). `choose_enemy`'s refresh-reroll loop is direct Python control flow, bounded by `maxArenaRefreshTimes`. Config knobs carried over from reference defaults: `ArenaComponentNumber`=1, `ArenaLevelDiff`=0, `maxArenaRefreshTimes`=10, `ArenaStopFightWhenRank1`=False | Done. Live-tested for real across all 5 of the account's daily tickets (2 WIN, 1 LOSE, 2 spent debugging the result-modal detection — see `plan.md` Phase 13 for the full writeup). Real navigation differences confirmed live: Tactical Challenge is a Work-hub card, not a bottom-nav icon; the reference's separate opponent-info and formation-edit screens are merged into one modal here with a live ticket-preview; `navigation.is_modal_open`'s shared probe reads *inverted* on this screen (own `_is_modal_open` via `config.ARENA_MODAL_PROBE`). Three real bugs fixed: a level-OCR crop too small for tesseract despite looking legible (fixed by widening the crop, not the pipeline); level text being bright-on-dark unlike every other OCR read in this project (fixed via `read_int_white_on_dark`); and the result-modal detection cycling through two failed color-based designs (fixed by switching to bounded blind-Enter dismissal with a hard safety gate — a real near-miss of the same "mistimed keypress" hazard class `CLAUDE.md` already documents from story_sweep). Deliberately opt-in only, never in `DEFAULT_ORDER` — unlike every other opt-in task so far (which spend a known-safe resource on a config-driven target list), this one fights a real ranked PvP battle that can win or lose and moves the account's actual arena rank. Per explicit user decision: fights exactly one battle per invocation, matching the reference's own per-call pacing (its `next_time = 55` background-thread rescheduling has no equivalent in this project's one-shot CLI). Not yet exercised live: the "no ticket" mid-flow race, an actual reroll click (every opponent offered was already an acceptable level), and `ArenaStopFightWhenRank1`'s rank-1 stop condition — all implemented per the reference's logic, just not yet hit by real game state. `detector.find_template`/`template_visible` (generalized named-template matcher, built during scaffolding) ended up unused — state detection stayed OCR/color-probe-driven throughout, like every other task in this project. Follow-up (2026-07-11): live bug report — this task never returned home at the end, so a second consecutive invocation starting from wherever the first left the game (the Tactical Challenge screen itself) sent `_open_tactical_challenge`'s home-relative clicks to the wrong place and failed all 3 retries. Fixed by calling the shared `navigation.return_to_home(driver)` at the very start of `run()`, reusing the generic Escape-based recovery primitive built for `event_sweep.py`'s own wrong-page recovery. Confirmed live (2026-07-11): reproduced the stuck-on-arena-screen scenario manually, then a real `arena` invocation recovered and completed a full fight normally (rank 14位's opponent-list entry moved 10位→9位, ticket 2→1, credits +1,080), with no repeat of the original failure. Follow-up #2 (2026-07-12), per explicit user request: reversed the original "exactly one battle per invocation" decision — `_fight_one` now holds the single-battle logic and `run()` loops it (`while tickets > 0 and fights < config.ARENA_MAX_FIGHTS_PER_RUN`), re-reading the OCR'd ticket count after each fight and waiting `config.ARENA_POST_BATTLE_COOLDOWN` (30s, per the user's own info about the real in-game lockout between fights) before continuing. `ArenaStopFightWhenRank1` is now re-checked before every fight in the loop, not just once. See `plan.md`'s Phase 13 follow-up #2 — not yet live-tested (spends multiple real tickets, needs the user's go-ahead first). **This DID subsequently run live via cron and surfaced a real hazard (2026-07-17)**: the log showed "battle skip already on" for all 5 fights and a clean "fought 5 battle(s) this run", but the account was left showing a real "Battle Complete" screen with a ~3-minute combat timer -- battles had run in full, not skipped -- and every task for the rest of that `daily` invocation failed to open its own screen. Two compounding bugs: (1) `_ensure_skip_on`'s "on" detection was never actually verified to reject a real "off" state (`ARENA_SKIP_ON_RGB` was only ever confirmed against a session where skip happened to already be on); (2) `_wait_for_result` had no early-exit signal and always ran its full fixed 15s budget before unconditionally declaring success -- far too short for a real battle -- and `_fight_one` discarded `_wait_for_result`'s return value entirely, so even a correctly detected failure never stopped the fight loop. Fixed by widening `RESULT_MODAL_MAX_POLLS` substantially (tolerating a real battle's full duration regardless of whether the skip-mode detection gets fully root-caused) plus a new `_back_on_challenge_list` early exit (so the normal fast case doesn't slow down), and by making `_fight_one` actually stop on a `_wait_for_result` failure. The skip-toggle's own "off" detection was not independently re-verified (would need a real ticket at a moment the account had none left) -- deliberately structured so the fix is safe even if that root cause isn't fully resolved. | -| Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | Originally ported as fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, matching the reference's own external-price-list-index design as closely as this repo's missing external table allows. **Revised 2026-08-01**: fixed position turned out not to hold live -- this shop sinks sold-out items to the bottom of its grid and shifts everything after them up, so `(row, col)` drifted the moment anything sold out (see plan.md Phase 25 follow-up #2). `select_targets_by_name` now identifies items by OCR'd name instead, scanning/scrolling the full (scrollable, ~24-slot) grid and matching on name + exact price + a purchasable-button color check -- arguably *closer* to the reference's item-identity-based design intent than the original position table was, just using a local OCR read in place of the reference's external price-list index. Tactical Shop stays on the original position-based path (never showed this symptom). Purchase-confirm dialog + reward-acquired banner still handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`) | Done, re-verified live post-rewrite: real run correctly bought the one target that was actually available (`中級レポート`, 125,000 credits, matching a real credit-balance delta) and correctly logged the other 7 as unavailable this cycle (sold out, or only available at an escalated repeat-purchase price the user chose not to chase) rather than misdiagnosing them as a catalog change. Per-refresh-cycle purchase cap (discovered in the original live test below) is now expected/handled behavior, not a surprise: sold-out items report as "not found available" and the grid position they vacate is exactly what the rewrite is designed to handle. Earlier finding, still true: no visible purchase-count indicator (unlike the 青輝石 tab's "あと1回購入可能" labels) | +| Common Shop | `module/shop/common_shop.py`, `module/shop/shop_utils.py` | `implement`, `to_common_shop`, `get_item_position`/`ensure_choose`/`buy` (shared, see Tactical Shop row) | `ba_auto/tasks/shop_common.py`, `ba_auto/tasks/shop_utils.py` | Originally ported as fixed grid-position targets (`config.COMMON_SHOP_TARGETS`) + price-digit OCR verify, matching the reference's own external-price-list-index design as closely as this repo's missing external table allows. **Revised 2026-08-01**: fixed position turned out not to hold live -- this shop sinks sold-out items to the bottom of its grid and shifts everything after them up, so `(row, col)` drifted the moment anything sold out (see plan.md Phase 25 follow-up #2). `select_targets_by_name` now identifies items by OCR'd name instead, scanning/scrolling the full (scrollable, ~24-slot) grid and matching on name + exact price + a purchasable-button color check -- arguably *closer* to the reference's item-identity-based design intent than the original position table was, just using a local OCR read in place of the reference's external price-list index. Tactical Shop stays on the original position-based path (never showed this symptom). Purchase-confirm dialog + reward-acquired banner still handled via a single overlay-darkness probe (`config.SHOP_OVERLAY_PROBE`). **Rewritten again 2026-09-17** after a real live under-purchase (reported by the user with their own cron log pasted in: a real `daily` run bought only 4 of 8 configured targets, logging the other 4 as "not found available" even though, hours later with nothing else touched, those exact 4 sat unbought at their exact expected prices right at the top of the unscrolled grid) -- see the Status column for the full root-cause writeup; fixed three distinct bugs, not one | Done, re-verified live post-2026-08-01-rewrite (superseded by the 2026-09-17 fixes below): real run correctly bought the one target that was actually available (`中級レポート`, 125,000 credits, matching a real credit-balance delta) and correctly logged the other 7 as unavailable this cycle rather than misdiagnosing them as a catalog change. **2026-09-17 live debugging** (superpowers:systematic-debugging, root-caused from the user's own pasted `~/repo/scratchpad/ba_logs/daily.log` excerpt cross-checked against live screenshots -- see `ba_auto/tasks/shop_utils.py`'s own docstrings for the full writeup) found and fixed THREE separate real bugs, confirmed one at a time via five consecutive real `./ba_dailies.sh shop_common` runs against the account's actual daily catalog: (1) `select_targets_by_name`'s old row/col loop kept iterating with stale screenshot-implied positions after a successful buy, even though buying an item collapses its grid slot and shifts every later item forward -- fixed by re-scanning the current viewport from scratch (`_scan_current_view`) after every single buy instead of continuing the old pass; (2) even with that fix, two more confirmed-available targets were still missed, because the checkbox's own "checked" confirmation settles well before the rest of the grid's reflow/collapse animation visually finishes -- fixed with `config.SHOP_REFLOW_SETTLE_WAIT` (1.5s, not yet tuned down from a measured minimum), an explicit wait before the post-buy rescan; (3) the deepest bug: Common Shop's own tier-naming convention nests a shorter tier name inside a longer one (`上級強化珠`/Advanced is a literal substring of `最上級強化珠`/Highest, same for the レポート pair), and the original substring-only name match had no preference for an exact match, so scanning a "最上級..." card while its "上級..." sibling was still also a remaining target could resolve to the WRONG (shorter) target, compare against the wrong price, and permanently skip a genuinely-available card every run -- confirmed live: `最上級強化珠` sat available at its exact listed price across three consecutive real runs, never once bought, until fixed via `_match_name`'s exact-match-first resolution (substring fallback now prefers the longest/most-specific candidate). All 8 of today's configured targets were confirmed successfully bought across the debugging session's real runs (credit balance deltas matched each purchase's exact expected price sum throughout), and a final clean run correctly reported all 8 as sold out for the day with no further false skips. Earlier finding, still true: no visible purchase-count indicator (unlike the 青輝石 tab's "あと1回購入可能" labels). Also encountered but unrelated to this task: a stray already-open MomoTalk panel intercepted a `SHOP_ICON` click mid-session (closed manually); `navigation.is_on_subscreen`/`is_modal_open` don't recognize MomoTalk as a blocking state, same class of blind spot already documented for the old gem_shop dialog and cafe's rank-up cutscene -- not fixed here, out of scope for this incident, flagged for future attention if it recurs | | Tactical Shop | `module/shop/tactical_challenge_shop.py`, `module/shop/shop_utils.py` | `implement`, `goto_shop_by_name`, shared `get_item_position`/`ensure_choose`/`buy` | `ba_auto/tasks/shop_tactical.py`, `ba_auto/tasks/shop_utils.py` | `goto_shop_by_name`'s OCR swipe-search over the shop-tab list → fixed click (`config.SHOP_TAB_TACTICAL`): this account's tab list is only 7 entries and fits on screen with no scroll needed, confirmed live, so there's nothing to search for — not an OCR-avoidance shortcut. Same grid-position + price-OCR-verify + overlay-probe design as Common Shop, sharing `shop_utils.run_shop_tab` | Done. Live-tested with real purchases (both configured AP-recovery drinks bought; AP and tactical-coin balance changes matched exactly) | | Lesson/Schedule | `module/lesson.py` | `implement`, `to_lesson_location_select`/`to_select_location`/`to_all_locations` (nav state machine), `get_lesson_region_num`/`switch_lesson_region_page`/`to_lesson_region` (paged region nav), `get_lesson_each_region_status`+`check_region_availability` (per-cell status via isometric-parallelogram pixel scan), `get_lesson_relationship_counts` (per-cell affection pip count via color count), `choose_lesson` (selection policy), `execute_lesson`/`to_location_info`/`start_lesson` (click cell -> info panel -> start -> result) | `ba_auto/tasks/lesson.py` | `picture.co_detect` -> `navigation.wait_for_state`-style bounded Enter-press loop (see below); the reference's paged-arrow region nav (needing OCR to know current position) -> this client renders the 12 regions as a scrollable list instead, which only ever settles at two scroll positions (`config.LESSON_REGION_ROW_Y`), so navigation is direct index-based clicking with nothing to OCR-locate; the reference's isometric `Parallelogram`/`Triangle` per-cell scan (tuned to the reference's own screen layout) -> reading each portrait's heart-shaped affection badge via a dedicated OCR path (`detector.read_int_on_heart_badge`) needs no isometric geometry at all | Done. Config-driven scope only in the sense of the *policy* (affection-first selection, sweep every unlocked region until tickets/lessons run out, no ticket purchasing, no favor-student targeting) -- unlike shop, no user-specific target list was needed since the reference's own `lesson_region_name.JP` (embedded directly in its `default_config.py`, not externally fetched) already names all 12 regions, used here only for logging. Live-tested for real: 5 real tickets spent across 3 regions with correct outcomes (ticket count, cleanup navigation, home-screen return all verified). Two real bugs were found and fixed from that run -- see below and `plan.md`'s Lesson phase. **Regression fix (Phase 12 follow-up)**: `LESSON_TICKET_OCR_RECT`'s left edge clipped in a stray katakana fragment next to the first digit, making tesseract drop the whole leading digit (`"7/7"` -> `"/7"`) and aborting every run outright; fixed by tightening the rect, re-validated with a full real run (7 tickets spent, correct count re-read after every schedule, clean stop and home-screen return). **Regression fix (Phase 12 follow-up #2)**: clicking the schedule icon doesn't always land on the Location Select list -- the game can resume directly on whichever region's per-region isometric map was last open (confirmed live: a previous run's Ctrl-C interruption left it stuck there, breaking `_open_region_grid` for every region in the next run identically). Fixed via a new `lesson._ensure_location_select_list` recovery check (detects the per-region map's own "すべてのスケジュール" button already showing before any row's been clicked, and returns via the back button if so); validated by deliberately reproducing the stuck state and confirming a real run recovered, spent the account's real remaining ticket, and finished cleanly. **Selection-policy rewrite (Phase 12 follow-up #3, 2026-07-13)**: per explicit user direction, replaced "always pick the single highest affection value" with a tiered min/max-farming priority -- any 3-student cell anywhere first, then any 2-student cell anywhere, then single-student cells sorted lowest-affection-first. This needs the whole board's state before deciding, not just the current region's, so the flow is now scan-all-then-execute (`_scan_all_regions`/`_build_priority_queue`/`_run_queue`) rather than the old per-region sweep-and-pick-best loop (`_find_best_cell`/`_sweep_region`, both removed). Verified offline against a synthetic board (correct tier ordering) and live against the real board via a zero-cost scan-only call (69 real schedulable cells found across 12 regions -- 7 triples, 30 doubles, 32 singles -- correctly bucketed and the singles tail exactly ascending by real affection value), with zero tickets spent since the account was fully out that day. Real ticket-spending execution (`_run_queue` actually running schedules) is not yet live-tested -- deferred to the user once tickets regenerate. | | Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `sweep_hard_task` (main flow, 20 AP/hit, 3x cap), `to_hard_event`/`to_region` (nav), `start_sweep`'s named-outcome contract (shared with story_sweep.py's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/story_sweep_hard.py` | Reference's `unfinished_hard_tasks` persisted config list -> a fixed, user-supplied priority-ordered list (`config.HARD_STORY_SWEEP_TARGETS`: 18-3, 30-3, 27-3, 28-3, 17-3, 13-3, 23-2, 16-1, 20-3, 17-2, 15-2, 14-3, 10-3, 14-2, 7-1, 5-3, 4-1), swept in that fixed order every run rather than popped/persisted across runs; each target uses MAX (capped at 3x by the game itself), matching `config.STORY_SWEEP_TARGETS`'s own MAX convention. Reuses story_sweep.py's region-nav/stage-row-OCR/modal/result-watching machinery where the Hard screen's layout matches (region browser, stage list, sweep modal) rather than re-deriving it. New: a campaign-active guard with no reference equivalent at all (not present anywhere in baas-reference) -- per explicit user direction, OCRs for the pink "キャンペーン中" text (confirmed live on both the home screen and the Hard region browser) before spending any AP, skipping the whole run if no campaign is active unless an explicit override is passed | Implemented, not yet live-tested. Live investigation on nik-gpu (2026-07-20) confirmed: the Hard tab (`config.HARD_TAB`) sits atop the same region-browser screen story_sweep.py already opens, and stays selected across region navigation (no re-click needed between regions, unlike the reference's own per-move to_hard_event re-assertion); Hard's stage list always shows exactly 3 fixed rows (missions 1-3, no "-A" bonus, no scrolling -- `config.HARD_STAGE_ROWS_Y`, replacing story_sweep's scroll+OCR row search entirely); the stage-info modal is pixel-identical to story_sweep's own (all of `SWEEP_MAX_BUTTON`/`SWEEP_START_BUTTON`/`STAGE_MODAL_CLOSE_BUTTON`/confirm-dialog constants reused directly, confirmed via a real zero-AP-spent dry run: opened the confirm dialog, screenshotted it, cancelled). The in-panel "キャンペーン中" campaign banner (`config.HARD_CAMPAIGN_BADGE_RECT`) was confirmed live on two different regions (30 and 29) with identical text ("任務Hardで獲得できる報酬量が2倍(+100%)になっています"), read as account-wide rather than per-region -- chosen over the home screen's own ambiguous "キャンペーン中" badge (which can point at unrelated concurrent campaigns) as the actual gate signal. **Also confirmed live**: this modal has the exact same two-stacked-action-button hazard bounty.py hit for real (cyan 掃討開始 "start sweep" directly above gold 任務開始 "start mission", a real manual battle, both showing an identical AP-cost preview) -- ported bounty.py's own `_confirm_dialog_is_sweep` OCR text-gate proactively here rather than waiting to discover the same hazard live a second time. **Live-tested for real across three rounds (2026-07-20/21) -- see `plan.md` Phase 21 for the full writeup.** Multiple genuine Hard sweeps confirmed for real (H18-3/H30-3 both fully exhausted their daily 3/3 clears; six more targets swept for real in the final run). Five real bugs found and fixed: (1) a navigation-cascade bug where every exit path's leftover "if on subscreen, press Escape" cleanup backed out of the region browser entirely after every single target, breaking all subsequent targets -- fixed via a new `_ensure_hard_screen` self-heal check per loop iteration, replacing the removed Escape presses; (2) a false-negative MAX-click verification that wrongly aborted 11 real, affordable targets once AP dropped below 40 (MAX legitimately caps at 1x there, indistinguishable from "click failed") -- fixed by dropping the verification gate and trusting the downstream confirm-dialog checks instead; (3) a genuinely new real-money hazard found live: a stage already at 0/3 remaining today still shows a clickable 入場 button, and sweeping it raises a real "spend 40 gems to refill today's clears?" dialog -- fixed with a two-layer guard (`_remaining_sweeps_today`'s pre-check OCR on the modal's own count field, `_is_challenge_count_alert`'s OCR fallback on the dialog text, both declining via Escape, never a positional click; deliberately reproduced twice with zero gems spent, confirmed by screenshot); (4) a cosmetic `"unrecognized_state"` misreport on every real sweep, root-caused to the same terminal-state gap event_sweep.py hit historically (post-sweep flow can return to the bare region browser instead of leaving the modal open) -- fixed by porting event_sweep.py's own `clicked_any`-gated second `ends` condition; (5) a still-not-fully-root-caused region-navigation flakiness (30->27 failed identically across two runs) mitigated with a short OCR-read retry (`REGION_READ_RETRIES`), not yet independently re-confirmed live since account AP was exhausted (down to 6) by that point in the session. | diff --git a/ba_auto/tasks/shop_utils.py b/ba_auto/tasks/shop_utils.py index 0f6b0f4..640e8c2 100644 --- a/ba_auto/tasks/shop_utils.py +++ b/ba_auto/tasks/shop_utils.py @@ -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: + + while remaining: + match = _scan_current_view(driver, config, remaining) + if match is None: 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))) + 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)