feat(circle): Implement daily check-in for circle (guild) with reward handling

This commit is contained in:
Nik Afiq 2026-07-16 00:14:38 +09:00
parent dc0e8403a1
commit 88cb1a933a
5 changed files with 201 additions and 7 deletions

View File

@ -1148,3 +1148,14 @@ GEM_SHOP_ICON_RETRIES = 3
# animation was still playing, so this needs real patience, not just 1-2 # animation was still playing, so this needs real patience, not just 1-2
# tries. # tries.
GEM_SHOP_CLAIM_MAX_ATTEMPTS = 6 GEM_SHOP_CLAIM_MAX_ATTEMPTS = 6
# Circle (サークル / guild), ported from module/group.py. Home -> bottom-nav
# ソーシャル (Social) icon -> サークル (Circle) card, opening the circle's
# chat/member screen. Live-calibrated 2026-07-15 on nik-gpu (real account,
# already a circle member -- "not in a circle" per the reference's own
# group_join-club outcome was not exercised or ported, see circle.py's
# module docstring).
SOCIAL_ICON = (812, 1080)
# サークル card, leftmost of three (サークル/フレンド/助っ人) on the
# ソーシャル hub page.
CIRCLE_CARD = (463, 613)

File diff suppressed because one or more lines are too long

154
ba_auto/tasks/circle.py Normal file
View File

@ -0,0 +1,154 @@
"""Circle (サークル / guild) daily check-in. Ports module/group.py.
Reference flow (`implement`): to_main_page -> to_group. `to_group` reacts to
the 'main_page' rgb state by clicking a fixed screen position to open the
circle from the main page, polling (`picture.co_detect`) until one of three
image-template terminal states is reached: `group_sign-up-reward` (first
entry today, +10 AP granted, claimable from mailbox), `group_menu` (already
checked in today, just the plain circle menu), or `group_join-club`
(account isn't in a circle at all).
This project's client reaches the same screen via home -> bottom-nav
ソーシャル (Social) icon -> サークル (Circle) card (the reference's own
single fixed click is a single fixed *screen position* too, but on a
different, earlier menu layout than this client's bottom-nav + hub-page
structure). If this is the first entry today, a "今日のサークルへの参加報酬"
(today's circle participation reward) modal appears automatically (AP x10,
"報酬はメールボックスから受け取ることができます" -- claim later from
mailbox, matching the reference's own sign-up-reward outcome and this
task's explicit scope: enter only, no mailbox claim here) -- dismissed via
Enter/OK. If already checked in today, the same click sequence lands
directly on the サークル chat/member screen with no modal. Told apart via
the existing shared `navigation.is_modal_open`/`is_on_subscreen` -- no new
probe needed, pixel-confirmed live against real captures: the reward modal
dims `MODAL_DIM_PROBE` to ~(110,115,115) (is_modal_open true), the
already-checked-in screen has no modal but a proper opaque header bar
(~(247,250,252) at `SUBSCREEN_HEADER_PROBE`, is_on_subscreen true) --
`is_modal_open(driver) or is_on_subscreen(driver)` is true in both real
"reached the circle screen" states and false on both the true home screen
and the intermediate ソーシャル hub page (confirmed live: that hub page
keeps the home screen's own background art dimly visible behind its
card grid rather than a proper opaque header, so it reads dark/false at
`SUBSCREEN_HEADER_PROBE` too, same as home -- it does NOT have its own
reliable single-point signal distinct from home, unlike every other
subscreen in this project. Rather than inventing an uncalibrated probe for
that intermediate state, `_enter_circle` treats "click Social, click
Circle" as one combined navigation attempt and retries the whole pair if
the *final* destination isn't confirmed -- safe to repeat: both the Social
icon and the Circle card stay clickable no-ops if we're already partway
through this navigation on a retry).
The "not in a circle" case (reference's `group_join-club`) is NOT ported --
this account is already a circle member (confirmed live), and per explicit
user scope this task only performs the entry, nothing else. If ever run on
an account with no circle, whatever join-prompt screen appears is only
guaranteed to abort cleanly if it fails BOTH is_modal_open/is_on_subscreen
-- `run()` now calls navigation.return_to_home at the very start (see
below) specifically to close the more likely false-positive source instead
(a leftover subscreen/modal from a stuck prior run), but a join-prompt that
happens to render as an ordinary subscreen or modal could still be
misread as "already checked in" rather than "not in a circle". Acceptable
given this account is always a real member and the task's own low stakes
(silently skipping a free reward once, not spending anything) -- not
acceptable to leave unexamined if this is ever run against a different
account.
Live-calibrated 2026-07-15 on nik-gpu by driving the real flow end-to-end
(see plan.md's Circle phase): confirmed both real outcomes (the actual
first-entry-today reward dialog, +10 AP; and the already-checked-in
straight-to-chat state on immediate re-entry), and confirmed a single
Escape from the サークル screen returns directly to the true home screen in
one step (skipping back through the ソーシャル hub page) -- per explicit
user direction, this task presses Escape directly rather than clicking
`navigation.BACK_BUTTON`, matching mailbox.py's own end-of-task convention.
A `reference-parity-reviewer` pass the same day caught three real gaps in
the first version, all fixed:
1. `_reached_circle`'s generic is_modal_open/is_on_subscreen check could
false-positive from a stuck non-home starting state (a leftover modal/
subscreen from a prior run), reading "already checked in" on attempt 1
without either click having done anything -- the exact class of bug
arena.py/bounty.py already fixed for themselves by calling
navigation.return_to_home at the top of their own run(). Ported the
same fix here.
2. The reward-dismiss Enter press and the closing Escape were never
verified to have worked -- same "unverified keypress assumed to work"
shape as the cafe.py/gem_shop.py bugs fixed earlier this same session.
A dropped Enter press would leave the reward modal open, then the very
next line's unconditional Escape would risk cancelling it instead of
having claimed it -- silently declining the day's free AP with no
warning logged, non-recoverable until the next daily reset. Fixed with
`_dismiss_reward`/`_leave_circle`, both bounded retry-until-verified
loops matching gem_shop.py's `_claim_free_package`/`_close_gem_shop`.
3. `_enter_circle`'s retry loop had no driver.focus_game() escalation for
the XIGNCODE anti-cheat overlay -- confirmed to have actually occurred
live during this task's own calibration (it stole a BACK_BUTTON click
mid-session). Every other retry loop in this project bitten by this
(navigation.return_to_home, navigation.click_back, arena's own
navigation) escalates via focus_game() partway through its budget;
this one now does too, right before the final attempt, matching
click_back's own convention.
"""
from ba_auto import navigation
ENTER_RETRIES = 3
def _reached_circle(driver, config):
return navigation.is_modal_open(driver) or navigation.is_on_subscreen(driver)
def _enter_circle(driver, config):
for attempt in range(1, ENTER_RETRIES + 1):
if attempt == ENTER_RETRIES:
driver.focus_game()
driver.click(*config.SOCIAL_ICON)
driver.wait(1.5)
driver.click(*config.CIRCLE_CARD)
driver.wait(2)
if _reached_circle(driver, config):
return True
print(f"[circle] circle screen not detected after click (attempt {attempt}/{ENTER_RETRIES})")
return False
def _dismiss_reward(driver, config):
for attempt in range(1, ENTER_RETRIES + 1):
driver.keypress("Return")
driver.wait(1.5)
if not navigation.is_modal_open(driver):
return True
print(f"[circle] reward dialog still open after Enter (attempt {attempt}/{ENTER_RETRIES})")
return not navigation.is_modal_open(driver)
def _leave_circle(driver, config):
for attempt in range(1, ENTER_RETRIES + 1):
driver.keypress("Escape")
driver.wait(1)
if not _reached_circle(driver, config):
return True
print(f"[circle] circle screen still open after Escape (attempt {attempt}/{ENTER_RETRIES})")
return not _reached_circle(driver, config)
def run(driver, config):
driver.focus_game()
navigation.return_to_home(driver)
if not _enter_circle(driver, config):
print("[circle] could not confirm the circle screen opened, aborting without pressing further keys")
return
if navigation.is_modal_open(driver):
print("[circle] claiming today's circle check-in reward (+10 AP, claim later from mailbox)")
if not _dismiss_reward(driver, config):
print("[circle] warning: could not confirm the reward dialog closed")
else:
print("[circle] circle already checked in today")
if not _leave_circle(driver, config):
print("[circle] warning: could not confirm return to the home screen")
print("[circle] Done.")

View File

@ -3,13 +3,14 @@
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, gem_shop, lesson, mailbox, shop_common, shop_tactical, stamina, story_sweep from ba_auto.tasks import arena, bounty, cafe, circle, 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, "gem_shop": gem_shop.run,
"circle": circle.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,
@ -24,11 +25,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. gem_shop is the opposite case -- like mailbox/cafe/ # module docstring. gem_shop and circle are the opposite case -- like
# stamina, it only ever reclaims a genuinely free (0 yen), once-per-day # mailbox/cafe/stamina, they only ever reclaim a genuinely free (0 yen),
# resource with no choice to make (claim it or don't, nothing to select), # once-per-day resource with no choice to make (claim it or don't, nothing
# so it belongs in the default flow rather than opt-in. # to select), so they belong in the default flow rather than opt-in.
DEFAULT_ORDER = ["mailbox", "cafe", "stamina", "gem_shop"] DEFAULT_ORDER = ["mailbox", "cafe", "stamina", "gem_shop", "circle"]
# 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

28
plan.md
View File

@ -698,6 +698,34 @@ Added to `ba_daily.py`'s `TASKS` dict and, unlike every task added since Phase 8
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. 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. 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.
### Phase 17: Circle (Group/Club) daily check-in (2026-07-15)
Reference: `module/group.py`. Local: `ba_auto/tasks/circle.py`. Requested directly by the user: "enter circle (guilds)... grant you 10 AP (stamina) once, reset daily. Then you can claim it from mailbox. This implement only do circle enter and return to home screen. No need for mailbox claim." Reached via home → bottom-nav ソーシャル (Social) icon → サークル (Circle) card, per the user's own description.
Reference flow (`implement`): `to_main_page``to_group`, which reacts to the `main_page` rgb state by clicking a fixed screen position, polling (`picture.co_detect`) until one of three image-template terminal states: `group_sign-up-reward` (first entry today, +10 AP granted), `group_menu` (already checked in, plain menu), or `group_join-club` (account isn't in a circle — reference just warns and stops). Every step detected via fixed-region template matching, no OCR.
**Live-calibrated 2026-07-15** by manually driving the real flow end-to-end on nik-gpu — this **genuinely claimed the account's real circle check-in reward for the day** (the real "今日のサークルへの参加報酬" modal, AP x10, "報酬はメールボックスから受け取ることができます" — matching the reference's own sign-up-reward outcome and the user's own description exactly), then confirmed the "already checked in" state for real by re-entering immediately afterward (same click sequence lands straight on the chat/member screen, no modal). Both real states pixel-sampled and found to satisfy the existing shared `navigation.is_modal_open`/`is_on_subscreen` check with no new probes needed — first genuine case this session where the shared primitives worked as-is rather than needing a dedicated dialog probe (contrast gem_shop.py's `GEM_SHOP_DIALOG_PROBES`).
One real navigational subtlety found and deliberately *not* over-engineered: the intermediate ソーシャル hub page (between clicking Social and clicking Circle) has no reliable single-point brightness signal of its own — pixel-sampled live, it reads dark at `SUBSCREEN_HEADER_PROBE` same as the true home screen, because it keeps the home screen's own background art dimly visible behind its card grid rather than using a proper opaque header bar like every other subscreen in this project. Rather than inventing an uncalibrated probe for that transient state, `_enter_circle` treats "click Social, click Circle" as one combined attempt and retries the *whole pair* (bounded, `ENTER_RETRIES=3`) if the final destination isn't confirmed — justified as safe because both click targets are harmless no-ops if partially already there on a retry.
Also hit, live, mid-calibration: the recurring `XIGNCODE` anti-cheat overlay stole a `BACK_BUTTON`-coordinate click (same gotcha documented in the "Return-to-home audit follow-up #2" below) — recovered via the standard `windowactivate`+`windowraise` escalation, no code change needed since `driver.focus_game()`/`navigation.return_to_home` already handle this class of incident project-wide.
Per explicit user direction mid-session ("Instead of click back you can use Esc button"), the task returns home via a direct `driver.keypress("Escape")` rather than clicking `navigation.BACK_BUTTON` — confirmed live that a single Escape from the サークル screen returns straight to the true home screen in one step (skipping back through the intermediate ソーシャル hub page entirely), matching `mailbox.py`'s own established end-of-task convention rather than inventing a new one.
The reference's `group_join-club` ("not in a circle") outcome is deliberately not ported — this account is already a circle member (confirmed live), and per explicit user scope this task only performs the entry, nothing else.
Added to `ba_daily.py`'s `TASKS` dict and `DEFAULT_ORDER` (alongside mailbox/cafe/stamina/gem_shop) — same "reclaims something free, no decision to make" category.
**Live-tested for real** via the actual `./ba_dailies.sh circle` CLI path: correctly recovered from a flaky first click (same familiar "first click sometimes misses" pattern), correctly read the real "already checked in today" state left over from the manual calibration walkthrough, and returned cleanly to the confirmed true home screen. **Not yet live-tested**: the "first entry → claim reward" code path itself, since the account was already checked in for today by the manual calibration walkthrough before the module existed — same disclosed-gap shape as gem_shop's own "available → claim" path (Phase 16). Worth a follow-up check the next time the check-in resets and hasn't been claimed yet.
**A `reference-parity-reviewer` pass the same day, before this was reported done, caught three real gaps, all fixed:**
1. **`_reached_circle`'s generic terminal check could false-positive from a stuck non-home starting state (fixed).** The first version relied solely on `ba_daily.py`'s centralized `_ensure_home()`, which CLAUDE.md documents as explicitly best-effort, not a hard gate — if a run began while some other subscreen/modal was already open (a leftover from a prior task), `_reached_circle` would read true on attempt 1 before either click did anything, and the code would silently report "already checked in today" without ever attempting the real check-in, no warning logged. This also undermined the module's own stated justification for skipping the reference's `group_join-club` ("not in a circle") case — a join-prompt rendered as an ordinary subscreen or modal would trigger the exact same false positive. Fixed by calling `navigation.return_to_home(driver)` at the top of `run()`, porting the same fix `arena.py`/`bounty.py` already made for themselves for this identical class of bug.
2. **The reward-dismiss Enter press and the closing Escape were never verified to have worked (fixed).** Same "unverified keypress assumed to work" shape as the cafe.py/gem_shop.py bugs fixed earlier the same session — a dropped Enter press would leave the reward modal open, then the very next line's unconditional Escape would risk cancelling it instead of claiming it, silently declining the day's free AP with no warning and no way to retry until the next daily reset (amplified by `circle` running unattended in `DEFAULT_ORDER` every day). Fixed with `_dismiss_reward`/`_leave_circle`, both bounded retry-until-verified loops directly porting `gem_shop.py`'s `_claim_free_package`/`_close_gem_shop` pattern.
3. **`_enter_circle`'s retry loop had no `driver.focus_game()` escalation for the `XIGNCODE` overlay (fixed).** That overlay had already struck live during this task's own calibration session (stole a `BACK_BUTTON` click, recovered manually via `windowactivate`+`windowraise`) — every other retry loop bitten by this class of incident in this project (`navigation.return_to_home`, `navigation.click_back`, arena's own navigation) escalates via `focus_game()` partway through its budget; this one now does too, right before the final attempt, matching `click_back`'s own convention.
**Re-confirmed live** after all three fixes: same real `./ba_dailies.sh circle` run via the actual CLI, correctly read "already checked in today" with the new `return_to_home()` pre-check in place, no warnings from the new verified dismiss/close loops, confirmed via screenshot back at the true home screen.
### 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."