From 0ff3dca0b9b869b7d64d58d9619aeedbaf1bc6a4 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 17 Sep 2026 10:37:24 +0900 Subject: [PATCH] Implement dynamic OCR for gem shop sidebar navigation and free card status - Added `read_lines` function to `detector.py` for OCR of multiple text lines in a specified region, improving the ability to dynamically locate text in the UI. - Rewrote the gem shop task in `gem_shop.py` to utilize OCR for sidebar navigation and free card status checks, replacing fixed coordinates with dynamic text detection. - Introduced helper functions `_find_sidebar_label`, `_on_general_products_tab`, `_ensure_general_products_tab`, `_read_card_lines`, `_read_free_card_status`, and `_find_buy_button` to streamline the OCR process and improve reliability against UI changes. - Removed outdated color probe methods for checking the free card status, enhancing the robustness of the gem shop interaction flow. - Live-calibrated the new OCR functionality to ensure accurate detection of UI elements and their states. --- ba_auto/config.py | 95 +++++++++++++------ ba_auto/detector.py | 62 +++++++++++++ ba_auto/reference_notes/mapping.md | 2 +- ba_auto/tasks/gem_shop.py | 142 ++++++++++++++++++++++------- 4 files changed, 239 insertions(+), 62 deletions(-) diff --git a/ba_auto/config.py b/ba_auto/config.py index c15748b..d6918e8 100644 --- a/ba_auto/config.py +++ b/ba_auto/config.py @@ -1435,37 +1435,72 @@ BOUNTY_SWEEP_CONFIRM_TEXT_RECT = (605, 505, 1320, 615) # for the "available -> claim" path in general. Re-confirm the exact # first time gem_shop actually claims a package post-redesign. GEM_SHOP_ICON = (204, 345) -# 一般商品 (general products), 3rd item in the left sidebar -# (おすすめの商品/限定商品/一般商品/定額商品/青輝石/特別支援). Always -# clicked explicitly rather than relying on it already being selected -- -# even though it was the landing tab in this recalibration, per this -# project's own established discipline of never trusting a remembered/ -# default UI selection before a real click (see memory: BA count/quantity -# steppers remember last-used values; the same caution applies to any -# selected-tab state here). -GEM_SHOP_GENERAL_PRODUCTS_TAB = (196, 376) -# デイリー (daily), 1st of 3 sub-tabs (デイリー/ウィークリー/マンスリー) -# shown top-right once 一般商品 is open. Also always clicked explicitly for -# the same reason as GEM_SHOP_GENERAL_PRODUCTS_TAB above. -GEM_SHOP_DAILY_SUBTAB = (1330, 148) -# 毎日無料パッケージ card's own 購入 (purchase) button -- the only card -# shown under 一般商品 -> デイリー (fixed position, no scrolling/search -# needed). -GEM_SHOP_FREE_CARD_BUY_BUTTON = (576, 630) -# The free card's own status bar (just below its artwork, above the price -# button) reads a flat, highly distinct color depending on claim state -- -# see the RECALIBRATED note above for how each value was obtained: -# available ("一日にN回まで購入可能"): flat light blue ~(226, 236, 246) -# claimed ("一日に0回まで購入可能"): flat dark red ~(190, 56, 66) -# The two are far enough apart (light vs dark, blue-family vs red-family) -# that no OCR of the "0"/"N" count text is needed -- the reference itself -# doesn't OCR this either, it template-matches two whole separate -# "purchasable"/"non-purchasable" card images. -GEM_SHOP_FREE_CARD_STATUS_PROBE = (410, 485) -GEM_SHOP_STATUS_AVAILABLE_RGB = (226, 236, 246) -GEM_SHOP_STATUS_CLAIMED_RGB = (190, 56, 66) -GEM_SHOP_STATUS_TOLERANCE = 20 +# RECALIBRATED AGAIN 2026-09-17 after a real live failure ("free card status +# not recognized after click", 3/3 attempts, reported live with the run's +# own log). Root-caused by driving the real screen step by step on nik-gpu +# (scratchpad/probe_gem_shop_careful_nav.py/probe_gem_shop_general_tab_fixed.py): +# the sidebar gained a new 限定商品 (NEW) entry above 一般商品 sometime after +# the 2026-07-29 recalibration above, which is now +# おすすめの商品/限定商品/定額商品/一般商品/青輝石/特別支援/ステップアップ商品 +# (also note 定額商品 and 一般商品 swapped order from the 2026-07-29 comment, +# and a new ステップアップ商品 row was appended -- see memory: +# project_gem_shop_stepup_not_automated). The old fixed +# GEM_SHOP_GENERAL_PRODUCTS_TAB=(196, 376) coordinate, aimed at the 3rd slot, +# now lands on 定額商品's 3rd slot instead of 一般商品's real 4th slot -- +# confirmed live by screenshot, the run was silently landing on and reading +# an entirely unrelated tab's card every attempt, never able to match either +# status color there. Retrying the exact same wrong click three times could +# never recover, matching the reported symptom exactly. +# +# Per explicit user request ("make the button press dynamic. Read word then +# click the correct position of the word"), the sidebar click, the status +# read, and the buy-button click are no longer fixed coordinates/colors at +# all -- gem_shop.py now OCRs each of these dynamically every run via +# detector.read_lines() and clicks/parses whatever position the target text +# actually rendered at, so a future sidebar reorder (or another new entry +# above 一般商品) can't silently break this again the same way. See +# gem_shop.py's module docstring for the live OCR calibration writeup +# (scratchpad/probe_gem_shop_ocr_regions.py) -- both the sidebar +# (vertically-stacked labels) and the free card's own status/button text +# read cleanly via one detector.read_lines() call per region, in their +# normal (unselected, dark-text-on-light) state. +# +# The one exception is the デイリー/ウィークリー/マンスリー subtab row, +# which stays a fixed click below: it is a horizontal (side-by-side) layout, +# confirmed live NOT to segment reliably as one OCR region (tesseract +# fragments multi-column text far worse than the vertically-stacked sidebar +# or card regions -- see the module docstring), and unlike +# GEM_SHOP_GENERAL_PRODUCTS_TAB above, this exact coordinate was NOT the +# demonstrated root cause (it landed correctly every time in live testing). +# GEM_SHOP_WEEKLY_SUBTAB_LABEL_REGION below reads ウィークリー as a +# read-only "did we actually land on 一般商品" confirmation instead -- +# ウィークリー is never the tab gem_shop.py itself selects, so it stays in +# its normal unselected (OCR-readable) state every run. +# +# 一般商品 (general products) sidebar item -- OCR target, not a coordinate. +# Region covers the whole scrollable sidebar column so it keeps working +# regardless of how many entries render above 一般商品. +GEM_SHOP_SIDEBAR_REGION = (40, 130, 360, 900) +# ウィークリー subtab label -- OCR target used only to confirm we're on the +# 一般商品 tab (this subtab row only renders there); not clicked. +GEM_SHOP_WEEKLY_SUBTAB_LABEL_REGION = (1447, 120, 1660, 180) +# デイリー (daily), 1st of 3 sub-tabs (デイリー/ウィークリー/マンスリー) +# shown top-right once 一般商品 is open. Always clicked explicitly rather +# than relying on it already being selected, per this project's own +# established discipline of never trusting a remembered/default UI +# selection before a real click (see memory: BA count/quantity steppers +# remember last-used values; the same caution applies to any selected-tab +# state here). See the note above for why this one stays a fixed +# coordinate while the sidebar click does not. +GEM_SHOP_DAILY_SUBTAB = (1330, 148) +# 毎日無料パッケージ card's content area (title, status line, and 無料 buy +# button) -- the only card shown under 一般商品 -> デイリー (no +# scrolling/search needed). OCR target region, not a fixed button +# coordinate: gem_shop.py reads every text line in here and finds "無料" +# (the buy button) and the "◯回まで購入可能" status line dynamically, +# rather than assuming either one's exact position or color. +GEM_SHOP_FREE_CARD_REGION = (397, 220, 760, 680) GEM_SHOP_ICON_RETRIES = 3 # Bounds the "press Enter, re-check the free card's status" loop that diff --git a/ba_auto/detector.py b/ba_auto/detector.py index a66b5d6..39dc295 100644 --- a/ba_auto/detector.py +++ b/ba_auto/detector.py @@ -286,6 +286,68 @@ def read_int_bordered(region, psm=7, border=20): return int(digits) if digits else None +def read_lines(region, lang="jpn", psm=6, image=None): + """OCR `region` and return `(x, y, text)` for every distinct text line + found in it -- `(x, y)` is that line's on-screen center (for clicking + it) and `text` is the line's full content, re-joined left-to-right from + tesseract's own per-token boxes (CJK text gets segmented into several + word/glyph tokens even on one visual line). + + Unlike read_text/read_int (a single known-fixed crop, content only, no + position), this scans a region that may contain several stacked lines + at once and reports WHERE each one actually rendered -- for a caller + that needs to dynamically locate and click a label/button whose exact + position isn't a reliable fixed coordinate, e.g. a sidebar list that + reflows when the game adds a new entry above an existing one. Built for + gem_shop.py after a real live failure where a fixed sidebar coordinate, + aimed at 一般商品 (general products), silently started landing on + 定額商品 instead once the game added a new entry above it -- see + gem_shop.py's module docstring and config.py's "RECALIBRATED AGAIN + 2026-09-17" note for the full incident. + + Only confirmed reliable for vertically-stacked lines rendered in this + UI's usual dark-text-on-light styling (same assumption `_ocr_crop` + already makes elsewhere) -- confirmed live NOT to segment a horizontal + row of side-by-side labels (e.g. gem shop's own + デイリー/ウィークリー/マンスリー sub-tabs) reliably in one call; OCR + each column as its own small region instead for that shape. Also not + reliable against a selected/highlighted item's own inverted (light- + text-on-dark-pill) styling -- confirmed live to misread rather than + just fail quietly, so prefer checking for a different, still-unselected + marker over re-reading a label the caller itself just selected. + """ + x1, y1, x2, y2 = region + img = image if image is not None else driver.read_screenshot(OCR_SHOT_PATH) + crop = img[y1:y2, x1:x2] + gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) + _, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY) + upscaled = cv2.resize(thresh, None, fx=OCR_UPSCALE, fy=OCR_UPSCALE, interpolation=cv2.INTER_CUBIC) + + tess_config = f"--psm {psm}" + data = pytesseract.image_to_data(upscaled, lang=lang, config=tess_config, output_type=pytesseract.Output.DICT) + + lines = {} + for i, word in enumerate(data["text"]): + word = word.strip() + if not word: + continue + key = (data["block_num"][i], data["par_num"][i], data["line_num"][i]) + lines.setdefault(key, []).append((data["left"][i], data["top"][i], data["width"][i], data["height"][i], word)) + + results = [] + for tokens in lines.values(): + tokens.sort(key=lambda t: t[0]) + joined = "".join(t[4] for t in tokens) + lefts = [t[0] for t in tokens] + rights = [t[0] + t[2] for t in tokens] + tops = [t[1] for t in tokens] + bottoms = [t[1] + t[3] for t in tokens] + cx = (min(lefts) + max(rights)) / 2 / OCR_UPSCALE + cy = (min(tops) + max(bottoms)) / 2 / OCR_UPSCALE + results.append((x1 + round(cx), y1 + round(cy), joined)) + return results + + def read_int_on_heart_badge(region, psm=7, image=None): """OCR a small dark-navy digit rendered on lesson.py's pink/magenta heart-shaped affection badge. diff --git a/ba_auto/reference_notes/mapping.md b/ba_auto/reference_notes/mapping.md index e60b800..462ffa2 100644 --- a/ba_auto/reference_notes/mapping.md +++ b/ba_auto/reference_notes/mapping.md @@ -8,7 +8,7 @@ Maps each local feature to the corresponding `~/repo/baas-reference/module/...` | Mailbox | `module/mail.py` | `to_mail`, `implement` | `ba_auto/tasks/mailbox.py` | tap/click via xdotool, screenshot via scrot, `color.rgb_in_range` → `driver.color_at` pixel-probe check | Migrated: real Python, state-verified via color probe (no legacy bridge) | | Cafe | `module/cafe_reward.py` | `to_cafe` (its `relationship_rank_up` popup-handling now also ported, see below), `interaction_for_cafe_solve_method3`, `collect`, `invite_girl`/`invite_by_affection`/`checkConfirmInvite` (student invitation, added 2026-07-14) | `ba_auto/tasks/cafe.py` | `picture.co_detect`/`color.rgb_in_range` → `driver.color_at` pixel-probe checks; sparkle template match ported in-process into `ba_auto/detector.py` (`find_cafe_sparkle`, now multi-scale) | Migrated: real Python, state-verified via color probes (no legacy bridge). Pat loop now polls for the full attempt budget instead of stopping on the first miss (see `plan.md` Phase 6 follow-up) — not yet confirmed against a live sparkle since none was available during testing. `_dismiss_rank_up_if_shown` reuses `navigation.is_on_subscreen` to detect and clear the full-screen bond-rank-up cutscene after a pat (see `plan.md` Phase 6 follow-up: rank-up popups) — not yet live-confirmed against a real trigger. **Student invitation (2026-07-14)**: per explicit user direction, invite a student into each room before farming it, preferring highest affection, always skipping (never confirming) a candidate that would swap an already-seated student's costume or move one in from the other room — directly ports `invite_by_affection`/`checkConfirmInvite`'s own logic with the reference's default `cafe_reward_allow_exchange_student`/`cafe_reward_allow_duplicate_invite` both `False` (no local config exists to make either configurable). Live-calibrated against nik-gpu with **zero real tickets spent** — all 3 real dialog variants (normal confirm, same-room costume-swap warning, neighboring-room move warning) found and confirmed live, every one cancelled rather than confirmed during calibration. The heart-shaped affection badge OCR reuses `detector.read_int_on_heart_badge` directly (pixel-confirmed the same widget lesson.py's own badges use — digit pixels sampled RG, matching that function's exact masking assumption) rather than building a second OCR path. Dialog-type detection OCRs the title bar and checks for either warning's own distinctive substring (`衣装`/`隣`) rather than an exact match, mirroring `event_sweep.py`'s own `"終了"` substring-match reasoning. All 5 heart-badge reads and all 3 dialog classifications verified offline against the real saved calibration screenshots before deploying (exact match, zero mismatches). **Confirmed live with real tickets spent, both rooms**: room 1 correctly skipped one 衣装替え (costume-swap) candidate then invited row 1 cleanly; room 2 correctly skipped three consecutive 隣のカフェの生徒を招待 (neighboring-room-move) candidates (expected — room 1's own invite had just taken the account's highest-affection students) then invited row 3 cleanly. Both newly-invited students were immediately patted successfully in the same run (the "newly invited student can be farmed" requirement), income was claimed, and the task returned cleanly to the true home screen with no warnings anywhere in the log. **Horizontal camera panning (2026-07-14)**: per explicit user direction ("due to my screen size... move screen most right and most left then farm"), `_pat_room` now pans the room camera to its rightmost extreme via a new `driver.drag()` primitive (this project's first — distinct from `driver.scroll()`'s wheel-based gesture, which is for list widgets, not a room camera), farms there, pans to the leftmost extreme, farms there too — no vertical panning, per the user's own instruction. Ports the reference's `zoom_out`'s underlying intent (see the whole room regardless of width) via the user's own specified mechanism (panning) rather than the reference's (zoom). Live-confirmed drag-direction-to-reveal-side mapping, that HUD elements (status bar, ticket buttons) stay fixed regardless of pan (no camera reset needed afterward), and that one drag already reaches the true extreme (extra repeats are a confirmed no-op, kept as a safety margin). **Confirmed live with a real game-state change**: a full run patted a real sparkle in room 2 (score 0.997) specifically after panning to an extreme, while room 1 found nothing that run (expected — per-student cooldown). Clean end-to-end completion, no warnings. **Invite ticket cooldown bugfix (2026-07-14)**: real-usage report (with screenshot) that the invite step kept failing once the account's ticket went on cooldown — `_open_invite_list`'s original check (`not is_on_subscreen`) couldn't distinguish the real MomoTalk list opening from a "通知" cooldown notice ("待機時間が経過した後に、再度招待することができます。") opening directly instead, since both dim the header the same way; misread as "list opened", it sent `_ensure_invite_sort`/`_try_invite_row`'s fixed coordinates into a dialog that has none of them. Fixed by checking `navigation.is_modal_open` (the darker real-dialog reading) first — a dialog appearing before any row is clicked can only mean the ticket click raised one directly — and dismissing it via the shared `SWEEP_CONFIRM_BUTTON`, returning `False` so the existing "skip this room's invite" fallback handles it. **Confirmed live**: both rooms correctly detected and dismissed the cooldown notice with no cascading errors, task completed cleanly (exit 0, true home screen confirmed via screenshot). See `plan.md`'s Phase 6 follow-up #4. **Two further real-usage fixes (2026-07-15, `next_fix.md`)**: (1) the cooldown-notice dismiss above now presses Escape and verifies via `navigation.is_modal_open` with bounded retry, instead of a fixed-coordinate click that could miss and leave the notice open under the following pan drags; (2) `_dismiss_rank_up_if_shown` switched from `navigation.is_on_subscreen`'s single-pixel header probe (found live to misread some characters' rank-up cutscene art as bright) to a new `navigation.is_header_bar_visible`, requiring 8 spread-out header-row points to all read bright via one atomic multi-point capture (`driver.colors_at`, added alongside it). Both best-effort — not yet re-confirmed live post-fix. **A real rank-up happened naturally via cron (2026-07-16)**, reported live with a screenshot of the game left stuck on the cutscene — exposed a third bug, a timing gap rather than a threshold problem: `_dismiss_rank_up_if_shown` was only ever called once, immediately after a pat, with no wait beforehand, but the cutscene renders with its own client-side animation delay, so that single check could catch the tail end of the still-normal room view and conclude "clear" a beat before the actual cutscene appeared. Confirmed via a live pixel-check that the resulting stuck cutscene reads under `is_on_subscreen`/`is_modal_open`'s thresholds too (`SUBSCREEN_HEADER_PROBE` read `(180,227,244)`, r=180 < the 200 both checks need), so `navigation.return_to_home`'s generic cleanup also couldn't recover it — every "are we home" check downstream falsely agreed nothing was wrong, and the whole `q4h` cron run finished "successfully" with the game actually left stuck. Fixed by checking on every `_pat_current_view` poll iteration instead of only right after a pat, so a delayed cutscene appearance is caught (and dismissed) roughly a second later instead of never. **Confirmed live**: manually reproduced the exact stuck state left by the incident and confirmed a single Enter (the same action `_dismiss_rank_up_if_shown` already takes) cleared it back to the real cafe room view; the per-iteration check itself has not yet been re-exercised against a fresh live rank-up trigger end-to-end (same "could not force one on demand" caveat as the original fix). **`SPARKLE_CLICK_OFFSET` recalibrated (2026-07-17)**: reported live with a screenshot (`screenshots/cafe/bug_student_close_together.png`) — when two students stand close together, the original `(75, 47)` offset (ported byte-for-byte from `scripts/detect_and_click.py`, never independently verified) overshoots past the intended target's head and lands on a different, closer student instead; the click doesn't register, so `find_cafe_sparkle()` matches the same still-showing sparkle again next iteration, repeatedly clicking nearly the same point for the rest of the room's budget (confirmed live: 13 of 16 "pats" in one run clustered within a ~10px box). First recalibration attempt, `(0, 0)` (raw template-match center), was also wrong — reported live ("clicking the actual sparkle instead of the student head"): a coincidental ambient thought-bubble animation at the test position had been misread as a hover confirmation. Properly recalibrated via numbered candidate-point overlays (screenshots with several labeled offset options drawn on a real live sparkle; the user picked the correct one directly on-screen) against two independent real students in different poses (sitting at an arcade cabinet, lying on a couch) — both converged on the same small offset, `(51, 15)`. **Confirmed live** via the real `./ba_dailies.sh cafe` CLI path: 6 sparkles patted across both rooms at genuinely distinct coordinates with zero repeated-click clustering, clean `[cafe] Done.` and true-home return. **Room 2 now invites lowest affection, plus invited-student name logging (2026-08-13, see `plan.md` Phase 31)**: per explicit user direction, room 1 keeps inviting highest affection (`_invite_student(..., prefer_highest=True)`, the default) while room 2 now invites lowest (`prefer_highest=False`) by sorting the MomoTalk list ascending instead of descending — same row-walking/skip logic either way, no reference equivalent (`invite_by_affection` only ever applies one direction per call and never logs back a name). New `CAFE_INVITE_NAME_RECT_X`/`CAFE_INVITE_NAME_RECT_Y_OFFSET` OCR rect, live-calibrated against a real open invite list; psm=7 (this project's usual single-line default) badly garbled short 2-character names ("ミカ" → `"' ーー、テテー"`), fixed by switching to psm=8 (single word) plus a punctuation strip, re-verified live reading all 5 sampled rows exactly correct. | | Stamina/AP | `module/collect_daily_task_power.py` | `to_tasks`/`implement` | `ba_auto/tasks/stamina.py` | `color.rgb_in_range` → `driver.color_at`; reference's per-tab claim loop → live UI's single "一括受取" bulk-claim button + Enter | Migrated: Mission-panel claim done (see `plan.md` Phase 8). The gem shop's own Daily Free Power flow (`module/collect_daily_free_power.py`) is a separate task now — see the "Gem shop daily free package" row below. **Daily gem reward bugfix (2026-07-16)**: reported live by the user against a real account state — the reference's own `implement` checks TWO separate button regions (the main 一括受取 area, then a second, separate "claim daily pyroxenes" button checked independently after), but only the first was ported originally. Fixed by porting the second region too: a new `MISSION_DAILY_GEM_CLAIM_BUTTON` (config.py), reusing the same bright-yellow-vs-grey color-probe style as the existing `MISSION_CLAIM_PROBE`, clicked via `driver.click` (not Enter — unlike 一括受取, this button has no keybind shown on screen) with a click-then-verify retry loop matching this project's established discipline. **Confirmed live**: gem balance went from 12,384 → 12,404 (exactly the reward's stated +20), clean return to the true home screen, no warnings. | -| Gem shop daily free package | `module/collect_daily_free_power.py` | `implement`, `to_purchase_pyroxenes_menu`, `to_purchase_type`, `detect_free_power_availability`, `collect_daily_free_power`, `return_to_main_page` | `ba_auto/tasks/gem_shop.py` | Reference detects every step via `core.picture.co_detect` + fixed-region OpenCV template matching (no OCR anywhere in this reference flow) — ported here as plain color probes instead, matching this project's own established equivalent for a simple flat-color state-A/state-B difference (see `cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building new template assets. **Recalibrated 2026-07-29** after a real client UI update replaced the old 3-top-tab overlay dialog with a genuine full subscreen (left sidebar + デイリー/ウィークリー/マンスリー sub-tabs under 一般商品). The free card's own status bar now reads flat light blue (~(226,236,246)) when available vs flat dark red (~(190,56,66)) once claimed today — still no OCR needed, same high-contrast-flat-color reasoning as before, just re-read off the new layout. The old dedicated `GEM_SHOP_DIALOG_PROBES` 4-point "is this dialog showing" check is gone entirely: the redesigned screen is a real subscreen (own header, back arrow at the shared `navigation.BACK_BUTTON` position), confirmed live that `navigation.is_on_subscreen`/`return_to_home` now work correctly here, so `gem_shop.py` was simplified to share that machinery with mailbox/cafe/shop/lesson instead of the old bespoke Escape-only close. | Done, recalibrated. Original: live-calibrated against nik-gpu 2026-07-15 by manually driving the real dialog end-to-end via raw xdotool/scrot — genuinely claimed the account's real free package for the day (0 yen, confirmed +10 AP / +10,000 credits). Recalibration (2026-07-29): the new "already claimed" status color and every new coordinate (icon, sidebar tab, daily sub-tab, buy button, status probe, screen-open/close detection) were re-verified live on nik-gpu against the real redesigned screen (see `scratchpad/gem_shop_after_click.png`/`gem_shop_weekly_tab.png`/`gem_shop_after_close.png`) — confirmed the icon click still opens it, confirmed `is_on_subscreen`/`return_to_home` now correctly detect/close it, and confirmed the "already claimed" red status color directly off the real card (still claimed from the 2026-07-15 session's calibration run, today's own reset hadn't been claimed yet at investigation time but the account's daily reset had already occurred, so it was showing the post-claim state). The new "available" light-blue color was inferred from three other real, currently-purchasable cards under the ウィークリー tab (same shared status-bar UI component, all three reading identically) rather than from the daily free card itself, since that card wasn't in the available state at recalibration time — **not yet re-confirmed against the daily free card's own available state, or against the full claim-confirmation/reward-banner flow post-redesign**; worth a deliberate check the next time the package resets and hasn't been claimed yet. Added to `DEFAULT_ORDER` (unlike most recent additions): it only ever reclaims a genuinely free, once-per-day resource with no choice to make, matching mailbox/cafe/stamina's own "reclaim something free" category rather than the "spends a resource, needs opt-in" category. | +| Gem shop daily free package | `module/collect_daily_free_power.py` | `implement`, `to_purchase_pyroxenes_menu`, `to_purchase_type`, `detect_free_power_availability`, `collect_daily_free_power`, `return_to_main_page` | `ba_auto/tasks/gem_shop.py` | Reference detects every step via `core.picture.co_detect` + fixed-region OpenCV template matching (no OCR anywhere in this reference flow). Originally ported here as plain fixed-coordinate clicks + color probes, matching this project's own established equivalent for a simple flat-color state-A/state-B difference (see `cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`). **Rewritten 2026-09-17**: the left sidebar and the free card's status/buy-button are now located dynamically via OCR (`detector.read_lines`, new primitive) instead of fixed coordinates/colors — see the Status column for why. | Done, rewritten. Original: live-calibrated against nik-gpu 2026-07-15, genuinely claimed the account's real free package for the day (0 yen, confirmed +10 AP / +10,000 credits). Recalibrated 2026-07-29 after a UI update replaced the old 3-top-tab overlay dialog with a real full subscreen (left sidebar + デイリー/ウィークリー/マンスリー sub-tabs under 一般商品) — every coordinate/color re-verified live at that point. **Real live failure, reported by the user 2026-09-17**: `./ba_dailies.sh gem_shop` logged "free card status not recognized after click" 3/3 attempts then aborted. Root-caused (superpowers:systematic-debugging — `scratchpad/probe_gem_shop_careful_nav.py`/`probe_gem_shop_general_tab_fixed.py`, screenshots pulled back for direct visual inspection) to the sidebar gaining a new 限定商品 (NEW) entry above 一般商品 sometime after 2026-07-29, silently shifting 一般商品 from the 3rd to the 4th slot and swapping order with 定額商品 — the old fixed `GEM_SHOP_GENERAL_PRODUCTS_TAB=(196,376)` coordinate landed on 定額商品 every single attempt, reading an unrelated card's content and never matching either status color, exactly matching the reported symptom (a genuine "pressing the wrong button" bug, not a flaky click). Per explicit user request ("make the button press dynamic. Read word then click the correct position of the word"), fixed by adding `detector.read_lines()` (OCR a region, return `(x, y, text)` per detected line) and rewriting the sidebar click, the status read, and the buy-button click to all locate their target text dynamically each run instead of trusting a fixed position — calibrated live against real screenshots (`scratchpad/probe_gem_shop_ocr_regions.py`): the sidebar and the card's own text (`一日にN回まで購入可能`, `毎日無料パッケージ`, `無料`) all OCR cleanly as vertically-stacked lines in their normal (unselected) styling; a selected/highlighted sidebar item's inverted white-on-blue-pill styling was confirmed live to OCR unreliably (garbage text, not just a clean failure) even after trying an inverted threshold, so the design avoids ever needing to re-read a label the task itself just selected (checking for a still-unselected marker instead — see `_on_general_products_tab`, which checks for ウィークリー rather than whichever subtab is selected). The デイリー/ウィークリー/マンスリー subtab row was confirmed NOT the root cause (that exact fixed coordinate landed correctly every time in live testing) and was confirmed live NOT to OCR reliably as one horizontal region (tesseract fragments side-by-side columns far worse than stacked lines), so it deliberately stays a fixed click. **Confirmed live end-to-end** via a real `./ba_dailies.sh gem_shop` run post-fix: OCR-found the sidebar tab, correctly read "available", OCR-clicked the real 無料 button, and genuinely claimed the day's package — credits went from 373,773,507 to 373,783,507 (exactly +10,000) and AP rose accordingly, confirmed via before/after screenshots. Still added to `DEFAULT_ORDER`, unchanged from before — a pure free reclaim with no choice to make. | | Normal/Hard story AP sweep | `module/explore_tasks/sweep_task.py`, `module/explore_tasks/task_utils.py` | `to_region` (ported: OCR region-number readout + delta-click), a scoped-down `swipe_search_target_str` (ported: OCR stage-row label matching), `start_sweep`'s named-outcome contract (ported via `navigation.wait_for_state`, this project's scoped `co_detect` port) | `ba_auto/tasks/story_sweep.py` | OCR region/stage-name matching, ported for real (Phase 10) — replaces Phase 9's "next-region arrow stops advancing, then random stage" heuristic; modal closed via its own X button (Escape doesn't close it; X-button position re-calibrated per stage-layout variant, see Phase 10) | Done (see `plan.md` Phase 10, supersedes Phase 9; rewritten Phase 22 to match story_sweep_hard.py's Phase 21 fixes and add its own campaign guard). Config-driven exact `(region, stage, count)` targets (`config.STORY_SWEEP_TARGETS`), not latest-region/random-stage. Opt-in only, not in default flow. **2026-07-21 rewrite (Phase 22)**: per explicit user direction ("match the hard implementation on best practices"), ported story_sweep_hard.py's fixes back into this module — an explicit Normal-tab guard (`config.NORMAL_TAB`/`_open_normal_tab`, needed now that story_sweep_hard can run earlier in the same `q4h` preset and leave Hard selected), a loop-level self-heal (`_ensure_task_screen`) replacing the old per-path "Escape if on subscreen" cleanup that would have backed out of the region browser after every target (never triggered in production since this module only ever swept one rotation target per run, but a real latent bug), MAX/+ clicks no longer requiring a visible stepper raise as proof of success (same low-AP false-negative class Hard hit), `_click_plus` now resets to a known baseline via a new `SWEEP_MIN_BUTTON` before raising (closing a latent version of bounty.py's real overspend bug that this module's original `_click_plus_and_verify` never guarded against), a newly-added `_confirm_dialog_is_sweep` OCR gate against the two-stacked-button hazard (confirmed live 2026-07-21 that Normal's own tabbed modal has the identical gold 任務開始 button beneath 掃討開始 that config.py's own `SWEEP_MAX_BUTTON` comment already documented but this module never actually guarded against), the same `clicked_any`-gated `_watch_sweep_result` fix, and a `REGION_READ_RETRIES` OCR-read retry. Also added a genuinely new campaign-active guard (`config.TASK_CAMPAIGN_BADGE_RECT`, renamed from `HARD_CAMPAIGN_BADGE_RECT` once shared) — confirmed live to be pixel-identical rect/color to Hard's own banner, just reading "任務Normalで獲得できる報酬量が2倍(+100%)になっています" instead of "Hard". Confirmed live that Normal mode has no per-stage daily clear cap (no "残り回数" indicator anywhere), so Hard's gem-refill hazard does not apply here. `story_sweep_force` added as the override command. **Live-tested for real the same day**: a real MAX sweep of the day's rotation target (30-2) succeeded cleanly end-to-end on the first attempt — campaign guard passed, OCR stage-row match succeeded despite the known leading-digit misread ("20-2" read but the "2" suffix still matched), sweep confirmed and correctly detected as `"swept"` (not the old cosmetic misreport), clean return home. AP dropped from ~139 to 1 and credits rose by 13,966, confirming a real substantial MAX sweep. | | Event sweep | `module/sweep_activity.py`, `module/activities/activity_utils.py` | `activity_sweep` (main flow), `to_activity` (nav to the event's Story/Mission/Challenge tabs), `check_sweep_availability`/`color.check_sweep_availability` (SSS gate), `start_sweep`'s named-outcome contract (shared with story_sweep's, ported the same way via `navigation.wait_for_state`) | `ba_auto/tasks/event_sweep.py`, `ba_auto/navigation.py` (`return_to_home`) | `to_activity`'s bottom-nav-icon entry -> this client's home-screen event badge (`config.EVENT_BADGE_ICON`, confirmed live to be a rotating carousel -- see Status); the reference's config-string sweep-list parsing (arbitrary stage lists, per-stage float/fraction counts via `preprocess_activity_region`/`preprocess_activity_sweep_times`) -> a single date-ordinal-modulo rotation target over a fixed 9-12 sub-range, mirroring `story_sweep.py`'s own rotation, per explicit user direction; stage-number OCR (`detector.read_int`) replaces the reference's `swipe_search_target_str` template-button search, since this client's stage list only ever needs its bottom scroll extreme for the 9-12 target range | Done, with a live-reported bug fixed (see `plan.md`'s Phase 14 follow-up). Live-calibrated against nik-gpu 2026-07-10 against the currently-running "鉄道爆走事件" event (12 stages) -- zero real AP spent during calibration (every confirm dialog reached was cancelled via Escape, verified by the AP counter). The stage-info modal is structurally identical to story_sweep's (MIN/-/+/MAX stepper, same AP-confirm dialog reusing `SWEEP_CONFIRM_*`/`SWEEP_RESULT_BUTTON_REGION`), but simpler: no region navigation, one fixed modal layout confirmed across two different stages (09 and 12), and the modal closes on Escape (story_sweep's doesn't). The reference's SSS-availability gate for a never-cleared stage was never exercised (every stage 9-12 on this account was already 3-starred) -- `event_sweep.py` handles that gate the same defensive way story_sweep handles an unavailable target: if the MAX-button count-raise can't be verified, it aborts without spending AP rather than guessing. **Bug found on a real run**: the home-screen event badge turned out to be a rotating carousel (cycles between the current event's countdown and other notices, e.g. a finished event's leftover reward-claim reminder), so a click could land on a stale event's page instead. Fixed via a new shared `navigation.return_to_home` primitive (bounded press-back-until-home loop, built generically so other tasks can reuse it, per explicit user request) plus wrong-page detection in `_find_stage_row` (zero stage-row numbers OCR'd at all -> retry via `return_to_home`, up to 3 attempts). **Second bug found on the next real run** (after the above fix correctly recovered and correctly OCR'd the target row): the row's own 入場 (enter) button click had no retry, unlike every other click-then-confirm step in this module -- missed once, aborted the whole run. Fixed via `_open_stage_modal`, the same click-then-verify-then-retry pattern already used everywhere else in the file. **Third bug found on a third real run**: `_find_stage_row`'s wrong-page detection false-positived twice (a fresh navigation's list hadn't finished rendering on the first OCR pass) before self-correcting on the 3rd attempt, wasting expensive return-home retries on what was really just a timing race -- fixed with a cheap in-place rescan before concluding "wrong page." Also, once past that, the 掃討開始 (start sweep) click turned out to be the last bare, unretried click in the file -- fixed via `_click_sweep_start_and_verify`. **Fourth round, self-driven live iteration per explicit user direction (fix/deploy/run/screenshot/diagnose in a loop, no stopping to report)**: found two more root causes and reached the first confirmed real live sweep. (1) The badge carousel's auto-rotate timer is far slower than the retry window -- a run hit "wrong page" on all 3 attempts genuinely, confirmed by screenshot; fixed by discovering and clicking the carousel's own pagination dots directly (`config.EVENT_BADGE_DOT_X`) instead of hoping the ambiguous badge shows the right item. (2) A genuine cold-start settle delay (up to ~20s for the Quest tab's stage list to populate after a fresh navigation, most likely a one-time server round-trip), not a flaky race -- confirmed via standalone probe scripts polling the real OCR pipeline once/second for a minute; fixed by widening `STAGE_ROW_SCAN_ATTEMPTS`/`_RETRY_WAIT` to match. With both fixes, a real run completed end-to-end: 200 AP spent (10x MAX sweep), credits gained, clean return home, confirmed by screenshot -- see `plan.md`'s Phase 14 follow-up #4 for the full writeup. **Fifth round, reported by the user a day later**: the exact same wrong-page-looping symptom recurred. Per the user's explicit request, added a DIRECT check for the finished event's own "イベント期間が終了しました" text (Japanese OCR, newly installed on nik-gpu since this project previously only needed digit/English reads) instead of the indirect "zero valid rows" heuristic -- `config.EVENT_FINISHED_TEXT_RECT`/`_is_finished_event_page`, live-calibrated and confirmed both ways (exact phrase match on a real finished page, no false positive on the correct page). This immediately proved the badge carousel's dot-clicking (follow-up #4's fix) is NOT reliably controllable after all -- sometimes worked, sometimes didn't, on the identical badge state -- while simply waiting longer let the carousel's own auto-rotate timer land on the correct item independently. Fixed by widening `WRONG_PAGE_RETRIES`/`_WAIT` (3x3s -> 6x12s) to give the timer real room to cycle, keeping dot-clicking as a harmless supplementary nudge. Two more real sweeps confirmed this fully working (11x MAX sweep, 219 AP spent, credits gained, clean home return, confirmed by screenshot -- three real confirmed sweeps total across this whole investigation). Also found and fixed the real root cause of the lingering `"unrecognized_state"` cosmetic bug (not a budget issue after all): `_watch_sweep_result`'s "swept" condition required the stage modal to still be open, but this event's flow can auto-return all the way to the Quest list instead, a terminal state it never anticipated -- fixed with a second `ends` condition gated on having clicked at least one result button first. Not yet re-verified live (AP exhausted by the successful sweep). See `plan.md`'s Phase 14 follow-up #5 for the full writeup. **Sixth round (2026-07-11)**: the daily rotation picked stage 9 for the first time, hitting a previously-flagged-but-never-exercised gap -- rows 366/538 ("08"/"09") consistently misread by OCR ("2" and empty/None) on every psm mode, even though the crop looked completely clean by eye; confirmed NOT a navigation/timing bug since rows 710/883/1055 ("10"/"11"/"12") read fine in the same run. Root cause (`scratchpad/probe_ocr_fix_08_09.py`): tesseract's segmentation fails on this tight edge-to-edge crop (no whitespace margin) for a leading-zero digit pair specifically -- adding a plain white border around the upscaled crop before OCR fixed both digits exactly, at every psm mode, without affecting "10". Fixed via new `detector.read_int_bordered`, now used for all `EVENT_STAGE_ROW_Y` reads. **Confirmed live**: stage 9 found, sweep executed for real (AP 233->14, credits +5,892, screenshot-confirmed safe return home) -- the originally reported bug is fixed. The same run again logged `unrecognized_state`, proving follow-up #5's `clicked_any`-gate fix addressed a real but secondary issue, not the full story. Diagnosed at zero AP cost (`scratchpad/probe_result_button_fp.py`, checks `_find_result_button` against the plain Quest list with no sweep running): the shared `SWEEP_RESULT_BUTTON_REGION` (borrowed from `story_sweep.py`) reaches into this event's own character-art panel and false-positive-matches `SWEEP_CONFIRM_CYAN` there with no dialog showing at all, confirmed on both a wrong event page and the correct one's own plain list -- so `_watch_sweep_result` kept "finding" a result button after the real one was already dismissed, and its "modal closed, no result button" end condition could never match. Fixed with a new event_sweep-only `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to exclude the character-art panel, still comfortably covering the real buttons), confirmed live against the actual false-positive condition (now returns `None` where it previously didn't) -- not yet re-confirmed via a fresh full sweep since AP was too low that day. See `plan.md`'s Phase 14 follow-up #6. **Seventh round (2026-07-29), direct user bug report with exact log**: "when it enter wrong page it will just wait then exit on false positive instead of return home and retry" -- the log showed a single stray/partial OCR digit ('7', matching nothing real) appearing at one row position while the other 4 were still `None`, which the old `_find_stage_row` trusted as proof of a genuinely-rendered page (`stage_not_found`), a terminal outcome `run()` never retried (only `wrong_page` was retryable). Also found live while investigating (user supplied a real screenshot, `scratchpad/event_event.png`, of the actual finished-event page): `EVENT_FINISHED_TEXT_RECT`'s "authoritative" check had been silently broken since whatever event succeeded the one it was calibrated against -- re-tested live against a real capture, it read pure background-art garbage with no "終了" match at all, not just clipped text. Fixed three ways: (1) `_find_stage_row` now requires a "some rows read, target absent" conclusion to read back identically on two consecutive scans before trusting it (the all-`None` case already had this stabilize-before-trusting protection; the partial-read case didn't); (2) `EVENT_FINISHED_TEXT_RECT` re-calibrated from (1030,600,1810,750) to (1080,585,1760,635) against a fresh live capture; (3) `run()` now retries on `stage_not_found` the same as `wrong_page`. **Confirmed live immediately after deploying**: a real run landed on the finished-event page first (now correctly and immediately detected via the fixed text rect, instead of silently falling through to the fragile row-count heuristic), returned home, retried, landed on the actual current event ("出航!万魔紐", which succeeded the event this module's stage-list layout was originally calibrated against but happens to share the identical 12-stage/last-5-rows-visible template), read all 5 rows cleanly, found the target stage, and safely declined without spending AP once the MAX-count-raise couldn't be confirmed (that stage isn't sweep-eligible yet) -- the full wrong-page-to-correct-page recovery loop worked exactly as intended end to end. | | Circle (Group/Club) daily check-in | `module/group.py` | `implement`, `to_group` | `ba_auto/tasks/circle.py` | Reference's `picture.co_detect` polling against fixed screen positions + `rgb_possible`/`img_possible` template states → this client's own 2-click nav (home → bottom-nav ソーシャル icon → サークル card) verified with the existing shared `navigation.is_modal_open`/`is_on_subscreen` (no new probe needed — pixel-confirmed both real "reached the circle screen" states, with or without the reward modal, satisfy that combined check) | Done. Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end via raw xdotool/scrot — this **genuinely claimed the account's real circle check-in reward for the day** (+10 AP, confirmed via the real reward dialog "今日のサークルへの参加報酬... AP x10... 報酬はメールボックスから受け取ることができます"), which calibrated both real states from real data: the first-entry reward modal, and (by re-entering immediately after) the already-checked-in straight-to-chat state with no modal. Also live-confirmed a real `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click mid-calibration (same recurring gotcha documented elsewhere in this project) — recovered via the standard `windowactivate`+`windowraise` escalation. Per explicit user direction, the task presses Escape directly to return home (confirmed live: a single Escape from the サークル screen returns straight to true home, skipping back through the intermediate ソーシャル hub page) rather than clicking `navigation.BACK_BUTTON`. The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a member, and the task's scope (per explicit user direction) is entry only, no mailbox claim. The actual `circle.py` module was then live-tested for real via `./ba_dailies.sh circle`, correctly reading the "already checked in today" state left over from calibration and returning cleanly home. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the time the module existed — same disclosed gap shape as gem_shop's own "available → claim" path. Added to `DEFAULT_ORDER` alongside mailbox/cafe/stamina/gem_shop, since it's a pure free reclaim with no decision to make. **A `reference-parity-reviewer` pass the same day caught and fixed three real gaps**: a too-generic terminal-state check that could false-positive "already checked in" from a stuck non-home starting state (fixed via `navigation.return_to_home(driver)` at the top of `run()`, matching arena.py/bounty.py precedent); an unverified reward-dismiss Enter press and closing Escape (fixed via `_dismiss_reward`/`_leave_circle`, bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop`); and a missing `driver.focus_game()` escalation in the entry retry loop for the `XIGNCODE` overlay (which had already struck live during this task's own calibration) — fixed to match `navigation.click_back`'s own escalation convention. Re-confirmed live via `./ba_dailies.sh circle` after all three fixes. See `plan.md`'s Phase 17 for the full writeup. | diff --git a/ba_auto/tasks/gem_shop.py b/ba_auto/tasks/gem_shop.py index 6f695d4..c67aac0 100644 --- a/ba_auto/tasks/gem_shop.py +++ b/ba_auto/tasks/gem_shop.py @@ -9,12 +9,36 @@ wait for the reward) else log "already collected" -> return_to_main_page equivalent server concept in this project, so not ported. The reference detects every step via image template matching (no OCR -anywhere in this flow) -- this port uses plain color probes for the same -states instead (see config.py's "Gem shop daily free package" section for -the full live-calibration writeup and exact pixel values), matching this -project's own established equivalent for a simple, high-contrast -state-A/state-B visual difference, same as cafe.py's CLAIM_DISABLED_RGB or -stamina.py's MISSION_CLAIM_PROBE. +anywhere in this flow). This port originally used plain color probes for +the same states instead (see config.py's "Gem shop daily free package" +section for the original 2026-07-15/07-29 live-calibration writeup), +matching this project's own established equivalent for a simple, +high-contrast state-A/state-B visual difference, same as cafe.py's +CLAIM_DISABLED_RGB or stamina.py's MISSION_CLAIM_PROBE. + +REWRITTEN 2026-09-17 after a real live failure: the sidebar gained a new +限定商品 entry above 一般商品 sometime after the 2026-07-29 recalibration, +silently shifting 一般商品 down one slot and making the old fixed +GEM_SHOP_GENERAL_PRODUCTS_TAB coordinate land on 定額商品 instead -- +confirmed live via scratchpad/probe_gem_shop_careful_nav.py and +probe_gem_shop_general_tab_fixed.py, which walked the real screen step by +step and captured the wrong-tab landing directly. Every retry just +re-clicked the same wrong position, matching the reported symptom exactly +("free card status not recognized after click", 3/3 attempts). + +Per explicit user request ("make the button press dynamic. Read word then +click the correct position of the word"), the sidebar navigation, the +status read, and the buy-button click are now all OCR-driven via +detector.read_lines() rather than fixed coordinates/colors -- each one +finds its target text wherever it actually rendered this run and +reads/clicks that position, so a future sidebar reorder can't silently +break this the same way again. See config.py's "RECALIBRATED AGAIN +2026-09-17" note and scratchpad/probe_gem_shop_ocr_regions.py for the live +OCR calibration this was based on. The デイリー/ウィークリー/マンスリー +subtab row is the one piece that stays a fixed click -- it's a horizontal +layout that doesn't OCR-segment reliably as one region (see +detector.read_lines' own docstring), and it was not the coordinate that +actually broke. Live-calibrated 2026-07-15 by manually driving the real dialog end-to-end on nik-gpu, which genuinely claimed the account's real free package for the @@ -26,15 +50,14 @@ than this actual module. RECALIBRATED 2026-07-29 after a real Blue Archive client UI update replaced the old 3-top-tab overlay dialog (期間限定/青輝石/パッケージ) with a real full subscreen (left sidebar + デイリー/ウィークリー/マンスリー sub-tabs -under 一般商品). See config.py's "Gem shop daily free package" section for -the full writeup of what was re-verified live and what's still an inferred -value. The old `_dialog_open`/`_close_gem_shop` custom probes existed only -because the pre-redesign overlay dialog was invisible to +under 一般商品). See config.py for the full writeup of what was +re-verified live and what's still an inferred value. The old +`_dialog_open`/`_close_gem_shop` custom probes existed only because the +pre-redesign overlay dialog was invisible to navigation.is_on_subscreen/is_modal_open; the redesigned screen is a real -subscreen, confirmed live, so this module now shares the same +subscreen, confirmed live, so this module shares the same is_on_subscreen/return_to_home machinery every other subscreen-based task -(mailbox/cafe/shop/lesson) already uses, and those two custom probes are -gone. +(mailbox/cafe/shop/lesson) already uses. A `reference-parity-reviewer` pass on the original version caught a real bug: the final dialog-close step verified success via @@ -46,7 +69,9 @@ screen from home, confirmed live), which is what made switching to the shared navigation.return_to_home safe here. """ -from ba_auto import navigation +import re + +from ba_auto import detector, navigation def _open_gem_shop(driver, config): @@ -59,10 +84,74 @@ def _open_gem_shop(driver, config): return False -def _open_daily_general_tab(driver, config): +def _find_sidebar_label(driver, config, label): + """OCR the gem shop's own left sidebar for `label` and return its + actual on-screen position, or None if not found in its normal + (unselected) styling.""" + for x, y, text in detector.read_lines(config.GEM_SHOP_SIDEBAR_REGION): + if label in text: + return (x, y) + return None + + +def _on_general_products_tab(driver, config): + """The デイリー/ウィークリー/マンスリー subtab row only renders under + 一般商品 -- checking for ウィークリー specifically (rather than + whichever subtab gem_shop.py itself just selected) avoids ever needing + to OCR a selected/highlighted pill, which reads unreliably (see + detector.read_lines' docstring).""" + return "ウィークリー" in detector.read_text(config.GEM_SHOP_WEEKLY_SUBTAB_LABEL_REGION, lang="jpn", psm=7) + + +def _ensure_general_products_tab(driver, config): + if _on_general_products_tab(driver, config): + return True + for attempt in range(1, config.GEM_SHOP_ICON_RETRIES + 1): + pos = _find_sidebar_label(driver, config, "一般商品") + if pos is None: + print(f"[gem_shop] could not OCR '一般商品' in the sidebar (attempt {attempt}/{config.GEM_SHOP_ICON_RETRIES})") + driver.wait(1) + continue + driver.click(*pos) + driver.wait(1.5) + if _on_general_products_tab(driver, config): + return True + print(f"[gem_shop] general products tab not confirmed after click (attempt {attempt}/{config.GEM_SHOP_ICON_RETRIES})") + return False + + +def _read_card_lines(driver, config): + return detector.read_lines(config.GEM_SHOP_FREE_CARD_REGION) + + +def _read_free_card_status(driver, config): + for _, _, text in _read_card_lines(driver, config): + if "購入可能" not in text: + continue + counts = re.findall(r"(\d+)回", text) + if counts: + return "claimed" if counts[0] == "0" else "available" + return "unknown" + + +def _find_buy_button(driver, config): + """The free card's own 無料 (free) buy button, located by OCR rather + than a fixed coordinate. "無料" can also appear as a substring of the + card's title (毎日無料パッケージ) in the same region -- picking the + shortest matching line prefers the standalone button label over that + longer title line.""" + candidates = [(x, y, text) for x, y, text in _read_card_lines(driver, config) if "無料" in text] + if not candidates: + return None + x, y, _ = min(candidates, key=lambda c: len(c[2])) + return (x, y) + + +def _open_daily_general_tab(driver, config): + if not _ensure_general_products_tab(driver, config): + print("[gem_shop] could not confirm the general products tab opened") + return "unknown" for attempt in range(1, config.GEM_SHOP_ICON_RETRIES + 1): - driver.click(*config.GEM_SHOP_GENERAL_PRODUCTS_TAB) - driver.wait(1) driver.click(*config.GEM_SHOP_DAILY_SUBTAB) driver.wait(1.5) status = _read_free_card_status(driver, config) @@ -72,21 +161,12 @@ def _open_daily_general_tab(driver, config): return "unknown" -def _color_matches(rgb, target, tolerance): - return all(abs(c - t) <= tolerance for c, t in zip(rgb, target)) - - -def _read_free_card_status(driver, config): - rgb = driver.color_at(*config.GEM_SHOP_FREE_CARD_STATUS_PROBE) - if _color_matches(rgb, config.GEM_SHOP_STATUS_CLAIMED_RGB, config.GEM_SHOP_STATUS_TOLERANCE): - return "claimed" - if _color_matches(rgb, config.GEM_SHOP_STATUS_AVAILABLE_RGB, config.GEM_SHOP_STATUS_TOLERANCE): - return "available" - return "unknown" - - def _claim_free_package(driver, config): - driver.click(*config.GEM_SHOP_FREE_CARD_BUY_BUTTON) + pos = _find_buy_button(driver, config) + if pos is None: + print("[gem_shop] could not OCR the free package's buy button") + return False + driver.click(*pos) driver.wait(2) for _ in range(config.GEM_SHOP_CLAIM_MAX_ATTEMPTS): if _read_free_card_status(driver, config) == "claimed":