155 lines
7.9 KiB
Python

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