Nik Afiq dc0e8403a1 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.
2026-07-15 18:22:33 +09:00

144 lines
6.6 KiB
Python

"""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.")