feat: Implement gem shop daily free package automation

- Added `ba_auto/tasks/gem_shop.py` to automate claiming the daily free package from the gem shop.
- Integrated color probes for state detection instead of template matching.
- Updated `ba_daily.py` to include the new gem shop task in the default execution order.
- Documented the implementation and calibration process in `plan.md`, including live testing results and fixes for dialog handling.
- Addressed potential bugs related to dialog state verification and tab layout changes.
This commit is contained in:
Nik Afiq 2026-07-15 18:22:33 +09:00
parent bb0215cb04
commit dc0e8403a1
5 changed files with 292 additions and 4 deletions

View File

@ -1045,3 +1045,106 @@ BOUNTY_RESULT_BUTTON_MIN_PIXELS = 3000
# areas, choose random (mod%3), run the latest stage available." Same # areas, choose random (mod%3), run the latest stage available." Same
# date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*. # date-ordinal-modulo scheme as STORY_SWEEP_ROTATION_*/EVENT_SWEEP_ROTATION_*.
BOUNTY_SWEEP_COUNT = "max" BOUNTY_SWEEP_COUNT = "max"
# Gem shop daily free package (毎日無料パッケージ), ported from
# module/collect_daily_free_power.py. Reference reads: home-screen icon ->
# purchase-pyroxenes dialog -> パッケージ (package) tab -> the FREE card at a
# fixed position -> confirm-purchase notice -> reward. The reference detects
# every step via image template matching (no OCR anywhere in this flow) --
# this port uses plain color probes instead, matching this project's own
# established equivalent for simple enabled/disabled or state-A/state-B
# visual differences (see cafe.py's CLAIM_DISABLED_RGB, stamina.py's
# MISSION_CLAIM_PROBE) rather than building new template assets, since every
# state below reduces to a clean, high-contrast flat color rather than a
# complex shape needing find_cafe_sparkle-style matching.
#
# All coordinates/colors below are pixel-scanned from real scrot captures on
# nik-gpu at the native 1920x1200 (not estimated from the non-native-resolution
# screenshots/gem_shop/*.png reference photos -- confirmed those are NOT 1:1
# with real game coordinates, same finding as screenshots/cafe/student/'s own
# calibration history). Live-calibrated 2026-07-15 by manually driving the
# real dialog end-to-end (see scratchpad/gem_shop_*.png for the captures) --
# this walk-through also genuinely claimed the account's real free package
# for the day (0 yen, +10 AP / +10,000 credits, confirmed via before/after
# counter values), so the two states below are both real, not guessed.
GEM_SHOP_ICON = (204, 345)
# パッケージ (package) tab, third of three (期間限定/青輝石/パッケージ).
#
# KNOWN FUTURE-BREAKAGE RISK (flagged in review, not yet fixable): this is a
# single fixed coordinate calibrated only against the CURRENT 3-tab layout,
# where 期間限定 (time-limited) is present as the first tab. The reference's
# own to_purchase_type branches on exactly this -- it picks a different
# パッケージ click x depending on whether 期間限定 is currently showing,
# because removing it reflows the remaining tabs. screenshots/gem_shop/2.png
# (captured live the same day as this calibration) shows 期間限定's own
# on-screen countdown ("終了まであと6日" / "26.06.24〜26.07.29"), meaning
# this account's 3-tab layout is itself temporary and due to change around
# 2026-07-21 -- not a theoretical edge case. No real screenshot of the
# resulting 2-tab (青輝石/パッケージ) layout exists yet to calibrate the
# correct alternate x, so a second coordinate has NOT been guessed in here
# rather than grounded in a real capture, per this project's own
# no-guessing-at-pixel-values discipline.
#
# This is safe to leave as a known gap rather than a live hazard: if this
# coordinate goes stale, _open_package_tab's own status-probe check reads
# "unknown" (neither the available-navy nor claimed-red color, since it'd
# be looking at the wrong tab's content) and run() aborts cleanly via
# _close_gem_shop's verified close WITHOUT ever clicking a purchase button
# on whatever tab it actually landed on -- no currency-risk path exists
# through this failure mode. Re-calibrate against a real capture once the
# tab count actually changes (or the countdown resets on a new campaign
# and this account still shows 3 tabs, meaning it wasn't actually an issue).
GEM_SHOP_PACKAGE_TAB = (1345, 330)
# 毎日無料パッケージ card's own 購入 (purchase) button -- always the
# leftmost card, top row, of the package tab (fixed position, matching the
# reference's own fixed (385, 479)-style offset rather than a search).
GEM_SHOP_FREE_CARD_BUY_BUTTON = (573, 795)
# 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 --
# confirmed live by claiming the real package and diffing before/after
# captures pixel-for-pixel at this exact point:
# available ("一日に1回まで購入可能"): flat dark navy ~(41, 65, 90)
# claimed ("一日に0回まで購入可能"): flat dark red ~(144, 38, 47)
# r vs b relationship alone cleanly separates them (navy: b>r by ~50; red:
# r>b by ~100), so no OCR of the "0"/"1" 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 = (420, 710)
GEM_SHOP_STATUS_AVAILABLE_RGB = (41, 65, 90)
GEM_SHOP_STATUS_CLAIMED_RGB = (144, 38, 47)
GEM_SHOP_STATUS_TOLERANCE = 20
# Generic "is the 青輝石購入 dialog (or a notice/confirm card nested on top
# of it) currently showing" check -- navigation.is_on_subscreen/is_modal_open
# both proved unreliable here, confirmed live: this dialog overlays directly
# on the home screen (keeping its own header bar visible, unlike mailbox/
# cafe/shop's own full subscreen headers) so is_on_subscreen's header probe
# stays dark in both states; is_modal_open's (960, 200) probe lands on the
# dialog's own opaque white card interior rather than a dimmed backdrop, so
# it reads bright (not-dark) whether the dialog is open or not -- the exact
# same class of default-probe mismatch story_sweep.py/bounty.py already
# document for their own wide modals.
#
# These 4 points sit in the dialog card's plain gutter background (confirmed
# identical whichever of the 3 tabs is active, and also identical whether
# the base dialog or a nested confirm/notice card is showing on top) and
# read a tight, near-white, near-equal-RGB cluster there in every dialog
# state captured; the true home screen's own character art was confirmed
# NOT to coincidentally match this closely at ALL 4 spread-out points
# simultaneously (same multi-point-beats-single-point reasoning as
# navigation.is_header_bar_visible, added the same day for an unrelated
# cafe bug -- reused here independently since this dialog's false-positive
# risk is a different shape, not the header row).
GEM_SHOP_DIALOG_PROBES = ((960, 200), (960, 250), (1500, 600), (420, 600))
GEM_SHOP_DIALOG_PROBE_MIN_CHANNEL = 220
GEM_SHOP_DIALOG_PROBE_MAX_SPREAD = 15
GEM_SHOP_ICON_RETRIES = 3
# Bounds the "press Enter, re-check the free card's status" loop that
# advances through the confirm-purchase notice and the "報酬獲得!" reward
# banner after clicking 購入 -- confirmed live the reward banner's own entry
# animation did not accept input on the first 1-2 presses while its sparkle
# animation was still playing, so this needs real patience, not just 1-2
# tries.
GEM_SHOP_CLAIM_MAX_ATTEMPTS = 6

File diff suppressed because one or more lines are too long

143
ba_auto/tasks/gem_shop.py Normal file
View File

@ -0,0 +1,143 @@
"""Gem shop (青輝石購入) daily free package. Ports module/collect_daily_free_power.py.
Reference flow (`implement`): to_main_page -> to_purchase_pyroxenes_menu
(open the dialog via the home-screen 青輝石購入 icon) -> to_purchase_type
("package") -> detect_free_power_availability -> if purchasable,
collect_daily_free_power (click the free card, confirm the 0-yen purchase,
wait for the reward) else log "already collected" -> return_to_main_page
(close the dialog). Skips entirely on CN server (JP/Global only) -- no
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.
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
day (0 yen, confirmed +10 AP / +10,000 credits via before/after counter
values) -- see scratchpad/gem_shop_*.png for the captures. That exercised
the "available -> claim it" path for real, via raw xdotool/scrot rather
than this actual module. This module was then live-tested for real
against the resulting "already claimed today" state (see plan.md's Gem
shop phase) -- the "available -> claim" code path itself is implemented
against the same coordinates/logic already confirmed live, but has not
yet been exercised by this actual module end-to-end; treat as
implemented-but-unverified for that one path until a run happens naturally
on a day the package hasn't been claimed yet.
A `reference-parity-reviewer` pass the same day caught a real bug in the
first version of this module: the final dialog-close step verified success
via `navigation.is_on_subscreen`, but config.py's own writeup on
`GEM_SHOP_DIALOG_PROBES` already documents that probe as blind to this
specific dialog (reads the same whether it's open or closed) -- so that
check could never fail, making a stuck-open dialog unrecoverable by this
module (and, per the same blindness, by `navigation.return_to_home`'s
shared fallback too). Fixed by adding `_close_gem_shop`, which verifies
against the module's own correctly-calibrated `_dialog_open` instead, with
a bounded retry -- reused for both closing paths (the "unknown status"
abort and the normal end-of-run close), replacing the original's second,
separately-broken blind Escape in the abort path too.
"""
def _dialog_open(driver, config):
for x, y in config.GEM_SHOP_DIALOG_PROBES:
r, g, b = driver.color_at(x, y)
spread = max(r, g, b) - min(r, g, b)
if not (min(r, g, b) > config.GEM_SHOP_DIALOG_PROBE_MIN_CHANNEL and spread < config.GEM_SHOP_DIALOG_PROBE_MAX_SPREAD):
return False
return True
def _open_gem_shop(driver, config):
for attempt in range(1, config.GEM_SHOP_ICON_RETRIES + 1):
driver.click(*config.GEM_SHOP_ICON)
driver.wait(2)
if _dialog_open(driver, config):
return True
print(f"[gem_shop] dialog not detected after click (attempt {attempt}/{config.GEM_SHOP_ICON_RETRIES})")
return False
def _open_package_tab(driver, config):
for attempt in range(1, config.GEM_SHOP_ICON_RETRIES + 1):
driver.click(*config.GEM_SHOP_PACKAGE_TAB)
driver.wait(1.5)
status = _read_free_card_status(driver, config)
if status != "unknown":
return status
print(f"[gem_shop] package tab's free card not recognized after click (attempt {attempt}/{config.GEM_SHOP_ICON_RETRIES})")
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 _close_gem_shop(driver, config):
# Verify with the module's own _dialog_open probe rather than
# navigation.is_on_subscreen/is_modal_open -- both are documented above
# (see the "Generic... dialog... showing" comment in config.py) to read
# the same regardless of whether this specific dialog is open or closed,
# so a check against either can never fail and would silently mask a
# stuck-open dialog (caught in review before this was live-tested: the
# original version of this function used is_on_subscreen for exactly
# that dead check).
for attempt in range(1, config.GEM_SHOP_ICON_RETRIES + 1):
driver.keypress("Escape")
driver.wait(1.5)
if not _dialog_open(driver, config):
return True
print(f"[gem_shop] dialog still open after Escape (attempt {attempt}/{config.GEM_SHOP_ICON_RETRIES})")
return False
def _claim_free_package(driver, config):
driver.click(*config.GEM_SHOP_FREE_CARD_BUY_BUTTON)
driver.wait(2)
for _ in range(config.GEM_SHOP_CLAIM_MAX_ATTEMPTS):
if _read_free_card_status(driver, config) == "claimed":
return True
driver.keypress("Return")
driver.wait(2)
return _read_free_card_status(driver, config) == "claimed"
def run(driver, config):
driver.focus_game()
if not _open_gem_shop(driver, config):
print("[gem_shop] could not confirm the gem shop dialog opened, aborting without pressing further keys")
return
status = _open_package_tab(driver, config)
if status == "unknown":
print("[gem_shop] could not read the free package card's status, aborting without pressing further keys")
if not _close_gem_shop(driver, config):
print("[gem_shop] warning: could not confirm the dialog closed")
return
if status == "claimed":
print("[gem_shop] daily free package already collected today")
else:
print("[gem_shop] claiming daily free package (+10 AP, +10,000 credits)")
if _claim_free_package(driver, config):
print("[gem_shop] claimed daily free package")
else:
print("[gem_shop] warning: could not confirm the free package was claimed after retries")
if not _close_gem_shop(driver, config):
print("[gem_shop] warning: could not confirm the dialog closed")
print("[gem_shop] Done.")

View File

@ -3,12 +3,13 @@
import sys import sys
from ba_auto import config, driver, navigation from ba_auto import config, driver, navigation
from ba_auto.tasks import arena, bounty, cafe, event_sweep, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep from ba_auto.tasks import arena, bounty, cafe, event_sweep, gem_shop, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep
TASKS = { TASKS = {
"mailbox": mailbox.run, "mailbox": mailbox.run,
"cafe": cafe.run, "cafe": cafe.run,
"stamina": stamina.run, "stamina": stamina.run,
"gem_shop": gem_shop.run,
"story_sweep": story_sweep.run, "story_sweep": story_sweep.run,
"event_sweep": event_sweep.run, "event_sweep": event_sweep.run,
"shop_common": shop_common.run, "shop_common": shop_common.run,
@ -23,8 +24,11 @@ TASKS = {
# rather than reclaiming something free, which is a real resource decision # rather than reclaiming something free, which is a real resource decision
# the default unattended run shouldn't make blindly. Arena specifically # the default unattended run shouldn't make blindly. Arena specifically
# fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's # fights a real ranked PvP battle each run -- see ba_auto/tasks/arena.py's
# module docstring. # module docstring. gem_shop is the opposite case -- like mailbox/cafe/
DEFAULT_ORDER = ["mailbox", "cafe", "stamina"] # stamina, it only ever reclaims a genuinely free (0 yen), once-per-day
# resource with no choice to make (claim it or don't, nothing to select),
# so it belongs in the default flow rather than opt-in.
DEFAULT_ORDER = ["mailbox", "cafe", "stamina", "gem_shop"]
# How many times _ensure_home retries navigation.return_to_home as a whole # How many times _ensure_home retries navigation.return_to_home as a whole
# (not to be confused with that function's own internal # (not to be confused with that function's own internal

37
plan.md
View File

@ -344,6 +344,18 @@ Fix: check `navigation.is_modal_open` (the darker "real dialog" reading, not the
**Confirmed live** (`live-test-runner` agent, real `cafe` run with the ticket confirmed still on cooldown for both rooms): both rooms logged `invite ticket click opened a dialog instead of the list (title: '...') -- likely on cooldown, treating invitation as unavailable` immediately, no cascading errors or misclicks followed, and the task proceeded normally to farming/income-claim and ended cleanly (exit 0, true home screen confirmed via screenshot). OCR of the dialog title came back noisy (`'通和 X'` instead of `'通知'`) but this doesn't matter — the fix's branch doesn't depend on the title text, only on `is_modal_open` firing before any row was clicked. **Confirmed live** (`live-test-runner` agent, real `cafe` run with the ticket confirmed still on cooldown for both rooms): both rooms logged `invite ticket click opened a dialog instead of the list (title: '...') -- likely on cooldown, treating invitation as unavailable` immediately, no cascading errors or misclicks followed, and the task proceeded normally to farming/income-claim and ended cleanly (exit 0, true home screen confirmed via screenshot). OCR of the dialog title came back noisy (`'通和 X'` instead of `'通知'`) but this doesn't matter — the fix's branch doesn't depend on the title text, only on `is_modal_open` firing before any row was clicked.
#### Phase 6 follow-up #5: rank-up cutscene misread + cooldown-notice dismiss misalignment (2026-07-15)
Two real bugs reported together in `next_fix.md` after a live `cafe` run:
**Bug 1**: a pat that triggered a real 絆ランクアップ (bond rank-up) cutscene caused the run to skip the rest of that room's farming instead of self-healing through it, matching a gap CLAUDE.md had already flagged as theoretical ("is_on_subscreen's fixed header probe reads inconsistently across different characters' cutscene art") — now confirmed live. Root cause: `_dismiss_rank_up_if_shown` checked `navigation.is_on_subscreen`, a single header pixel at `(500,10)`; for this run's character, that pixel happened to read bright, so the loop believed the cutscene had already cleared without ever pressing Enter, then `_pat_current_view`'s loop kept polling `find_cafe_sparkle()` against the still-showing cutscene for the rest of its budget. Fixed by adding `navigation.is_header_bar_visible` (8 spread-out header-row x-positions, all must read bright — a real header bar is uniformly flat-colored across its width, unlike a photo-real character composition) and switching `_dismiss_rank_up_if_shown` to use it. Best-effort: could not force a live rank-up on demand to confirm end-to-end this session.
**Bug 2**: when the invite ticket is on cooldown, the cooldown-notice dismiss (added in follow-up #4 above) clicked `SWEEP_CONFIRM_BUTTON`'s fixed coordinate, which could miss this single-OK notice's own button (not necessarily laid out like the two-button dialogs that coordinate was calibrated against) — a missed click left the notice open, and the camera-pan drags that followed landed on the still-open dialog instead of the room view. First fix attempt switched to a plain `driver.keypress("Escape")`; an independent `reference-parity-reviewer` pass caught that this was itself unverified (an unconfirmed keypress is no more trustworthy than the unconfirmed click it replaced, and this project's own `config.py` documents at least one dialog where Escape does *not* close it) — so the actual fix presses Escape and verifies with `navigation.is_modal_open`, retrying up to `ROOM_OPEN_RETRIES` times and logging clearly if it's still stuck, rather than assuming one press worked.
A related efficiency finding from the same review: `is_header_bar_visible`'s 8-point check was calling `driver.color_at()` 8 times (8 full-screenshot round-trips) for one logical check. Added `driver.colors_at(points)` — one screenshot, multiple points sampled from it, also atomic (no drift across sequentially-captured frames) — and switched `is_header_bar_visible` to use it.
Both fixes deployed to nik-gpu; the reviewer independently pixel-verified `is_header_bar_visible` against real captured screenshots (`screenshots/cafe/room1.png`, `screenshots/cafe/student/04_*.png`) and found no false-negative risk against normal cafe/dialog screens. Neither fix has been re-confirmed against a fresh live rank-up or cooldown notice post-deploy — worth watching the next time either occurs naturally.
### Phase 7: setup.sh update ### Phase 7: setup.sh update
**Status: Done — `setup.sh` deploys `ba_daily.py` and `ba_auto/`.** `scripts/ba_dailies_legacy.sh` and `scripts/detect_and_click.py` were deleted once mailbox and cafe both migrated off them (Phases 56); `setup.sh` no longer references either. **Status: Done — `setup.sh` deploys `ba_daily.py` and `ba_auto/`.** `scripts/ba_dailies_legacy.sh` and `scripts/detect_and_click.py` were deleted once mailbox and cafe both migrated off them (Phases 56); `setup.sh` no longer references either.
@ -372,6 +384,10 @@ Two real bugs found and fixed during live calibration, both worth remembering fo
Not yet done: Group/Club AP, and Daily Free Power (see above). Not yet done: Group/Club AP, and Daily Free Power (see above).
#### Phase 8 follow-up: Daily Free Power built as its own task (2026-07-15, see Phase 16)
The 青輝石購入 menu deliberately left unautomated above turned out to gate a real-money purchase UI in general, but the specific card this was always about (`module/collect_daily_free_power.py`'s 毎日無料パッケージ, 0 yen, AP+credits) is safe to automate on its own — it's a fixed, non-selectable, always-free claim with no purchase decision to make, same category as this phase's own Mission-panel claim. Built as a separate `ba_auto/tasks/gem_shop.py` rather than folded into `stamina.py`, since it's a different reference file/UI entry point. See Phase 16.
### Phase 9: Normal/Hard story AP sweep ### Phase 9: Normal/Hard story AP sweep
**Status: Done.** Read `module/explore_tasks/sweep_task.py` and `module/explore_tasks/task_utils.py` — the reference flow reads the current region number and matches stage-name text via OCR (`swipe_search_target_str`) to navigate to a configured target stage, then runs a per-stage claim loop. This client exposes a much simpler path to the same goal (burn AP via already-3-starred stages) that avoids porting the OCR-based lookup entirely: each stage's own 任務情報 (task info) modal has a self-contained 掃討 (sweep) sub-panel with a MIN/-/+/MAX count stepper and a start button. **Status: Done.** Read `module/explore_tasks/sweep_task.py` and `module/explore_tasks/task_utils.py` — the reference flow reads the current region number and matches stage-name text via OCR (`swipe_search_target_str`) to navigate to a configured target stage, then runs a per-stage claim loop. This client exposes a much simpler path to the same goal (burn AP via already-3-starred stages) that avoids porting the OCR-based lookup entirely: each stage's own 任務情報 (task info) modal has a self-contained 掃討 (sweep) sub-panel with a MIN/-/+/MAX count stepper and a start button.
@ -661,6 +677,27 @@ With that fixed, the user approved spending the account's one remaining ticket o
**Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here). **Confirmed real**: 2 live sweeps total, credits gained both times (+180,000 then +36,000), clean automatic return home both times, zero manual intervention, zero real Pyroxene spent despite the near-miss. **Not yet re-confirmed live**: a fresh sweep with today's fix deployed (account is at 0/6 tickets as of session end) and any bulk/MAX-count sweep's result-screen flow specifically (only count=1 was ever tested, since ticket scarcity forced it — a bulk sweep may show a SKIP-then-OK sequence like story_sweep/event_sweep rather than the single-OK dialog confirmed here).
### Phase 16: Gem shop daily free package (2026-07-15)
Reference: `module/collect_daily_free_power.py`. Local: `ba_auto/tasks/gem_shop.py`. Requested directly by the user with reference screenshots (`screenshots/gem_shop/1-4.png`): claim the 毎日無料パッケージ (daily free package, +10 AP / +10,000 credits, 0 yen, once per day) inside the 青輝石購入 (gem purchase) dialog. This is the exact piece Phase 8 deliberately deferred (see its follow-up above) — that phase stopped at "this menu has real-money purchase buttons visible immediately," but the specific free card is a fixed, non-selectable, no-decision claim, same category as Phase 8's own Mission-panel claim.
Reference flow (`implement`): `to_main_page``to_purchase_pyroxenes_menu` (open the dialog via the home-screen icon) → `to_purchase_type("package")``detect_free_power_availability` → if purchasable, `collect_daily_free_power` (click the free card, confirm the 0-yen purchase, wait for the reward) else log already-collected → `return_to_main_page` (close the dialog). Every step is detected via `core.picture.co_detect` + fixed-region OpenCV template matching against per-locale PNG assets (`main_page_purchase-pyroxenes-{menu,package-selected-*,daily-free-purchasable,daily-free-non-purchasable,confirm-purchase-notice}.png`) — no OCR anywhere in this reference flow.
Ported as plain color probes instead of new template assets, matching this project's own established equivalent for a simple, high-contrast state-A/state-B visual difference (`cafe.py`'s `CLAIM_DISABLED_RGB`, `stamina.py`'s `MISSION_CLAIM_PROBE`) rather than building a template-match asset pipeline for what reduces to flat colors. **Live-calibrated 2026-07-15** by manually driving the real dialog end-to-end on nik-gpu via raw `xdotool`/`scrot` (not estimated from the user's own non-native-resolution reference screenshots — confirmed those aren't 1:1 with real game coordinates, same finding as `screenshots/cafe/student/`'s calibration history) — this walkthrough **genuinely claimed the account's real free package for the day** (AP 64→74, credits 161,391,144→161,401,144, both confirmed via before/after counter reads), which calibrated both the "available" and "already claimed" states from real data in one pass:
- The free card's own status bar reads flat dark navy `~(41,65,90)` when available ("一日に1回まで購入可能") vs flat dark red `~(144,38,47)` once claimed ("一日に0回まで購入可能") — a clean, high-contrast signal (`r` vs `b` alone separates them by ~50-100), no OCR of the count digit needed.
- `navigation.is_on_subscreen`/`is_modal_open`'s default probes both proved unreliable on this dialog specifically: it overlays the home screen directly (keeping the home header visible) rather than being a full subscreen, so `is_on_subscreen`'s header probe stays dark whether the dialog is open or not; `is_modal_open`'s `(960,200)` probe lands on the dialog's own opaque white card interior rather than a dimmed backdrop, so it reads bright regardless of dialog state too — the same class of default-probe mismatch `story_sweep.py`/`bounty.py` already document for their own wide modals. Fixed with a dedicated 4-point "is this dialog (or a nested confirm/notice card) showing" check (`GEM_SHOP_DIALOG_PROBES`), reusing the same multi-point-beats-single-point reasoning `navigation.is_header_bar_visible` was built on the same day for an unrelated cafe bug (see Phase 6 follow-up #5) — independently applied here since this dialog's false-positive risk is a different shape (character-art home screen vs. the dialog's own near-white card gutters), not the header row.
- The post-purchase "報酬獲得!" (reward acquired) banner did not accept input on its first 1-2 Enter presses while its own entry animation was still playing — `_claim_free_package` polls the free card's status after each press rather than assuming one press is enough, bounded by `GEM_SHOP_CLAIM_MAX_ATTEMPTS` (6).
Added to `ba_daily.py`'s `TASKS` dict and, unlike every task added since Phase 8, also to `DEFAULT_ORDER` — it only ever reclaims a genuinely free, once-per-day resource with no choice to make (unlike story_sweep/shop/lesson/arena/bounty, which all spend something on an automated choice), the same "reclaim something free" category as mailbox/cafe/stamina.
**Live-tested for real** via the actual `./ba_dailies.sh gem_shop` CLI path (not just the manual calibration walkthrough): correctly recovered from a flaky first icon click (retried and succeeded on attempt 2, the same "first click sometimes misses" pattern already documented for the mailbox/cafe icons), correctly read the real "already claimed today" state left over from the calibration walkthrough, logged it, and returned cleanly to the confirmed true home screen. **Not yet live-tested**: the "available → claim" code path itself, since the account's package was already claimed for the day by the manual calibration walkthrough before the module existed — it uses the same coordinates/logic already confirmed live via that manual walkthrough, but hasn't been exercised by the actual module end-to-end. Worth a follow-up check the next time the package resets and hasn't been claimed yet.
**A `reference-parity-reviewer` pass the same day, before this was reported done, caught a real bug and a real future-breakage risk:**
1. **Dead close-verification (fixed).** The first version's final dialog-close step verified success via `navigation.is_on_subscreen` — but that probe is documented (in the same session's config.py writeup) to read identically whether this specific dialog is open or closed, since the dialog overlays the home screen directly rather than being a full subscreen. The check could therefore never fail, silently masking a stuck-open dialog that neither this module nor `navigation.return_to_home`'s shared fallback (same blind-probe class) could then detect — the exact "unverified action assumed to work" shape the cafe.py bugs earlier in this same session were also caught in. Fixed by adding `_close_gem_shop`, which verifies with the module's own correctly-calibrated `_dialog_open` check instead, bounded-retry, reused for both the normal end-of-run close and the "unknown status" abort path (which had the same unverified-Escape bug separately). **Re-confirmed live** after the fix: same real "already claimed" run via the actual CLI, no warnings, confirmed via screenshot back at the true home screen.
2. **`GEM_SHOP_PACKAGE_TAB` is a single coordinate calibrated only against today's temporary 3-tab layout (documented, not yet fixable).** The reference's own `to_purchase_type` branches on whether the 期間限定 (time-limited) tab is present, because removing it reflows the remaining tabs — and `screenshots/gem_shop/2.png` (captured live the same session) shows that tab's own countdown ("終了まであと6日", 26.06.24〜26.07.29), meaning this account's 3-tab layout is itself due to change around 2026-07-21. No real screenshot of the resulting 2-tab layout exists yet, so a second coordinate was deliberately NOT guessed in rather than grounded in a real capture — flagged with a dated comment in `config.py` instead. Contained, not a live hazard: if this goes stale, `_open_package_tab`'s existing status-probe check reads "unknown" and aborts cleanly via the now-fixed verified close, without ever clicking a purchase button on whatever tab it actually landed on. Needs a real re-calibration pass once the tab count actually changes.
### Return-to-home audit (2026-07-12) ### Return-to-home audit (2026-07-12)
Per explicit user request: "I want all the script to return to home page after the script ended, no matter the script ended in success or failure. You can use the Esc button to return or use module to reuse the logic." Per explicit user request: "I want all the script to return to home page after the script ended, no matter the script ended in success or failure. You can use the Esc button to return or use module to reuse the logic."