- Added `login.py` to handle the login process, including detection of various states such as the title screen, network error notices, loading transitions, daily attendance cards, and the home screen. - Integrated a recovery mechanism to relaunch the game if it gets stuck during loading. - Updated `ba_daily.py` to include the login task in the default execution order, ensuring it runs first to navigate to the home screen before other tasks. - Fixed a bug in the home detection logic to accurately identify when the user is on the true home screen. - Live-tested and calibrated the login flow against real game states, confirming functionality and addressing any issues encountered during testing.
169 lines
5.8 KiB
Python
169 lines
5.8 KiB
Python
"""Local PC/Steam/Proton control backend (xdotool/scrot wrappers)."""
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
import cv2
|
|
|
|
from ba_auto import config
|
|
|
|
PROBE_SHOT_PATH = os.path.join(config.SCRATCHPAD_DIR, "probe.png")
|
|
|
|
|
|
def run_command(args, **kwargs):
|
|
kwargs.setdefault("env", config.ENV)
|
|
kwargs.setdefault("check", True)
|
|
return subprocess.run(args, **kwargs)
|
|
|
|
|
|
def focus_game():
|
|
result = run_command(
|
|
["xdotool", "search", "--name", config.WINDOW_NAME],
|
|
check=False, capture_output=True, text=True,
|
|
)
|
|
window_ids = result.stdout.split()
|
|
if not window_ids:
|
|
raise RuntimeError("Blue Archive window not found. Is the game running?")
|
|
run_command(["xdotool", "windowactivate", window_ids[0]])
|
|
# windowactivate alone isn't enough -- confirmed live (twice now, see
|
|
# Handoff.md's original finding and plan.md's Bounty phase for a second
|
|
# occurrence) that a stray anti-cheat XIGNCODE window can render
|
|
# visibly on top of the game and intercept clicks at fixed positions
|
|
# (notably the shared BACK_BUTTON coordinate) even while xdotool still
|
|
# reports BlueArchive as the "active" window -- windowactivate changes
|
|
# input focus, not stacking order, so it doesn't fix this by itself.
|
|
# windowraise does. Cheap and harmless when no overlay is present.
|
|
run_command(["xdotool", "windowraise", window_ids[0]])
|
|
wait(0.5)
|
|
|
|
|
|
def click(x, y):
|
|
# A combined "mousemove X Y click 1" invocation is unreliable here --
|
|
# empirically (see plan.md's click-flakiness writeups) the game's input
|
|
# handler sometimes misses clicks fired before it's processed the move.
|
|
# Splitting into separate commands with a short pause between fixes it.
|
|
run_command(["xdotool", "mousemove", str(x), str(y)])
|
|
wait(0.2)
|
|
run_command(["xdotool", "click", "1"])
|
|
wait(0.5)
|
|
|
|
|
|
def move_mouse(x, y):
|
|
run_command(["xdotool", "mousemove", str(x), str(y)])
|
|
|
|
|
|
def drag(start_x, start_y, end_x, end_y, duration=0.6, steps=12):
|
|
"""Click-and-drag gesture: mousedown at (start_x, start_y), move to
|
|
(end_x, end_y) over `duration` seconds in `steps` increments, mouseup.
|
|
|
|
Distinct from scroll()'s wheel-based gesture -- scroll()'s own comment
|
|
already documents that a plain drag does NOT register as a list-scroll
|
|
gesture in this Proton client, but that finding was specific to
|
|
scrollable list widgets (mailbox/lesson/bounty/etc.); a room-view
|
|
camera (e.g. cafe.py's horizontal pan) is a different UI surface, not a
|
|
list, and needs an actual drag rather than a wheel click.
|
|
"""
|
|
run_command(["xdotool", "mousemove", str(start_x), str(start_y)])
|
|
wait(0.2)
|
|
run_command(["xdotool", "mousedown", "1"])
|
|
step_delay = duration / steps
|
|
for i in range(1, steps + 1):
|
|
x = start_x + (end_x - start_x) * i // steps
|
|
y = start_y + (end_y - start_y) * i // steps
|
|
run_command(["xdotool", "mousemove", str(x), str(y)])
|
|
wait(step_delay)
|
|
run_command(["xdotool", "mouseup", "1"])
|
|
wait(0.3)
|
|
|
|
|
|
def scroll(x, y, direction, clicks=1):
|
|
# xdotool button 4/5 = scroll wheel up/down. A drag (mousedown/move/mouseup)
|
|
# does not register as a list-scroll gesture in this Proton client; the
|
|
# wheel does.
|
|
button = "4" if direction == "up" else "5"
|
|
run_command(["xdotool", "mousemove", str(x), str(y)])
|
|
wait(0.2)
|
|
for _ in range(clicks):
|
|
run_command(["xdotool", "click", button])
|
|
wait(0.15)
|
|
wait(0.3)
|
|
|
|
|
|
def keypress(key):
|
|
run_command(["xdotool", "key", key])
|
|
wait(0.5)
|
|
|
|
|
|
def screenshot(path):
|
|
run_command(["scrot", "-a", "0,0,1920,1200", "-o", path])
|
|
|
|
|
|
def color_at(x, y):
|
|
screenshot(PROBE_SHOT_PATH)
|
|
image = cv2.imread(PROBE_SHOT_PATH)
|
|
b, g, r = image[y, x]
|
|
return int(r), int(g), int(b)
|
|
|
|
|
|
def colors_at(points):
|
|
"""Sample multiple (x, y) points from a single screenshot, instead of one
|
|
scrot capture per point -- added for navigation.is_header_bar_visible's
|
|
multi-point header check, which otherwise called color_at() 8 times (8
|
|
full-screen captures) for one logical check. Also more correct than
|
|
looping color_at(): all points come from the exact same frame rather
|
|
than points sampled sequentially across several hundred ms of separate
|
|
captures, which could straddle a screen transition.
|
|
|
|
Returns a list of (r, g, b) tuples in the same order as `points`.
|
|
"""
|
|
screenshot(PROBE_SHOT_PATH)
|
|
image = cv2.imread(PROBE_SHOT_PATH)
|
|
colors = []
|
|
for x, y in points:
|
|
b, g, r = image[y, x]
|
|
colors.append((int(r), int(g), int(b)))
|
|
return colors
|
|
|
|
|
|
def wait(seconds):
|
|
time.sleep(seconds)
|
|
|
|
|
|
def kill_game():
|
|
# check=False -- pkill exits 1 with no output when nothing matches,
|
|
# which is a normal outcome here (e.g. the process already died on its
|
|
# own), not an error worth raising on.
|
|
run_command(["pkill", "-f", config.GAME_PROCESS_NAME], check=False)
|
|
|
|
|
|
def launch_game():
|
|
# The launch script (see config.GAME_LAUNCH_SCRIPT) blocks until the
|
|
# game process exits, by design (it's meant to be run as a foreground
|
|
# session launcher) -- Popen + start_new_session=True detaches it so
|
|
# this process can keep polling for the window instead of hanging for
|
|
# the whole play session.
|
|
subprocess.Popen(
|
|
[config.GAME_LAUNCH_SCRIPT],
|
|
env=config.ENV,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
stdin=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
|
|
|
|
def is_game_running():
|
|
result = run_command(
|
|
["pgrep", "-f", config.GAME_PROCESS_NAME],
|
|
check=False, capture_output=True, text=True,
|
|
)
|
|
return bool(result.stdout.strip())
|
|
|
|
|
|
def window_exists():
|
|
result = run_command(
|
|
["xdotool", "search", "--name", config.WINDOW_NAME],
|
|
check=False, capture_output=True, text=True,
|
|
)
|
|
return bool(result.stdout.strip())
|