diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a38df2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +local/ +build/ +dist/ +*.spec diff --git a/README.md b/README.md new file mode 100644 index 0000000..51c5bbb --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# 蔚蓝星球本地自动化 + +当前是本地 Python 桌面原型。先完成并验证单条流程,再扩展任务和打包成 macOS / Windows 独立程序。 + +## 已实现 + +- 桌面界面:主屏幕截图、框选游戏内容区域、流程预览、执行日志与停止。 +- 小金猪流程:首页识别 → 打开挂机奖励 → 检查标题和领取按钮 → 点击领取一次 → 等待按钮变化 → 关闭弹框 → 确认回到首页。 +- 脚本与识别模板分离,后续任务通过 `workflows/` 扩展。 +- 截图像素与鼠标坐标分别换算,覆盖主屏幕 Retina 缩放的坐标情况。 + +小金猪流程只领取挂机奖励,不执行消耗体力的快速扫荡。完成日志只表示已点击领取、按钮变化并返回首页,奖励实际到账仍需首次运行时人工核对。当前模板来自用户提供的界面截图,尚需实机验证。 + +红点规则:按钮右上角有红点表示其内部有奖励可领取。后续入口任务可以据此进入,但领取成功还需验证内部状态。当前第一条小金猪流程不依赖红点,红点检测尚未实现。 + +## 本地启动 + +### 已验证的特惠一键领取脚本 + +在首页执行: + +```sh +.venv/bin/python claim_offer.py +``` + +只检查首页和显示计划: + +```sh +.venv/bin/python claim_offer.py --dry-run +``` + +流程为:首页 → 特惠 → 若打开“超值月卡”则点击底部“战令” → 赏金战令 → 一键领取 → 确认“恭喜获得”弹框 → 关闭弹框 → 确认领取按钮变灰 → 返回首页。已于 2026-10-04 实际领取并返回首页,领取后奖励出现绿色勾且一键领取变灰。完整脚本再次运行时,已验证从月卡切换战令、识别灰色按钮并返回首页。 + +当前仅适配本次“赏金战令”领取,支持从“超值月卡”切换到“战令”,不会购买战令或遍历其他特惠标签。无法识别的页面样式或标签会使流程停止。macOS 自动读取已打开游戏窗口位置,每次识别重新定位,识别后窗口移动则停止;窗口标题栏高度按本次验证的 44 逻辑点处理,需收起微信侧栏。运行时保持游戏完整可见。Windows 使用 `main.py` 已校准的区域或 `--region X Y WIDTH HEIGHT`,实机尚未验证。 + +需要 Python 3.9 或更新版本,以及带 Tk 支持的 Python。macOS 若当前 Python 缺少 Tk,可使用 python.org 官方安装包。 + +macOS: + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt +.venv/bin/python main.py +``` + +Windows PowerShell: + +```powershell +py -m venv .venv +.venv\Scripts\python.exe -m pip install -r requirements.txt +.venv\Scripts\python.exe main.py +``` + +## 首次运行 + +1. 将游戏放在主屏幕,打开首页,保持窗口完整可见。 +2. 点击“校准游戏区域”。程序会最小化并在 1.5 秒后截图,在预览图中拖动框选**游戏内容**,不包含微信标题栏及边框。 +3. 点击“预览流程”,在 3 秒内切回游戏。只验证当前首页并显示计划,后续页面尚未验证。 +4. 预览通过后点击“执行领取”,在 3 秒内切回游戏,观察首次实际结果。 +5. 可以点击程序中的“停止”,或把鼠标移到主屏幕左上角触发 PyAutoGUI 紧急停止。系统截图调用期间停止可能延迟到调用结束。 + +macOS 实际截图和点击需要为运行程序的宿主启用屏幕录制和辅助功能权限。程序不会自行改变系统权限。Windows 的主屏幕缩放与窗口尺寸、macOS Retina 坐标映射均需实机验证;当前没有完成双平台验证。 + +“导入截图校准”需要导入当前**主屏幕完整截图**,不能导入单独的游戏裁剪图,否则无法得到桌面位置。校准配置保存到忽略版本管理的 `local/region.json`。游戏移动、大小改变、屏幕配置改变后请重新校准。当前不支持跨屏或遮挡运行,界面样式不匹配时停止,不强制点击。 + +## 验证与后续 + +### 实机操作排查记录(2026-10-04) + +通过 Codex 窗口控制工具尝试领取时,可以读取微信下的游戏画面,但点击小金猪未打开弹框。随后尝试直接绑定 `com.tencent.flue.WeApp` 超时,系统记录了 16:50:34 的 `WeApp` 崩溃:辅助进程启动后约 39 毫秒,在 `_libsecinit_appsandbox` 初始化中发生 `EXC_BREAKPOINT / SIGTRAP`。该记录支持异常发生在小游戏辅助进程启动阶段,尚不能证明微信主进程崩溃,也不能证明由点击动作直接导致。 + +本次操作使用窗口控制工具,未启动本项目 `main.py`,因此当前不能将该异常归因于本地原型。后续由用户从微信正常打开游戏,不再通过工具直接启动或绑定 WeApp 辅助程序;先验证单次截图和点击,再进行完整流程测试。本地原型只操作已打开的游戏区域,不负责启动微信或其辅助进程。 + +用户授权后进行了单次本地 PyAutoGUI 商城点击测试:沙盒外可读取主屏幕截图,但页面未切换;只读查询 `AXIsProcessTrusted()` 返回 `False`,确认本地 Python 进程缺少辅助功能权限。程序已增加点击前权限检查,权限缺失时停止并提示,不继续报告“已点击”。这不证明此前窗口工具不响应也出于相同原因。 + +用户授予权限后,查询返回已授予。普通点击将游戏窗口激活,但商城仍未打开;随后使用 PyAutoGUI 移动鼠标、按下保持 0.2 秒、释放,成功打开商城,游戏保持运行。本地原型已采用此方式,异常时也确保释放鼠标;这次验证仅证明商城切换有效,小金猪领奖流程仍待实机测试。 + +需要用户在“系统设置 → 隐私与安全性 → 辅助功能”中,为运行程序的宿主(当前为 ChatGPT/Codex;从终端启动则为对应终端)启用权限,之后重启宿主再检查。程序不自动授予或请求权限。可用以下命令只读确认: + +```sh +.venv/bin/python -m automation.permissions +``` + +```sh +.venv/bin/python -m unittest discover -s tests -v +``` + +测试使用模拟桌面,不操作游戏,覆盖坐标换算、遮罩识别、领取流程、预览、超时和停止。真实领取后的弹框变化尚未采集,如未回到首页则会超时停止,不重复领取。 + +后续依次补充红点检测、各任务具体步骤与结果识别、资源策略、模型设置与 Key 存储,再准备独立应用打包。目前不调用模型 API,也不保存模型 Key。 + +实现参考:[PyAutoGUI 官方文档](https://pyautogui.readthedocs.io/en/latest/quickstart.html)、[OpenCV 模板匹配文档](https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html)。 diff --git a/assets/claim.png b/assets/claim.png new file mode 100644 index 0000000..a6dd9f8 Binary files /dev/null and b/assets/claim.png differ diff --git a/assets/home.png b/assets/home.png new file mode 100644 index 0000000..25620a1 Binary files /dev/null and b/assets/home.png differ diff --git a/assets/idle_title.png b/assets/idle_title.png new file mode 100644 index 0000000..aa2d825 Binary files /dev/null and b/assets/idle_title.png differ diff --git a/assets/offer_back.png b/assets/offer_back.png new file mode 100644 index 0000000..ca5b5b6 Binary files /dev/null and b/assets/offer_back.png differ diff --git a/assets/offer_claim.png b/assets/offer_claim.png new file mode 100644 index 0000000..397fc6f Binary files /dev/null and b/assets/offer_claim.png differ diff --git a/assets/offer_claimed.png b/assets/offer_claimed.png new file mode 100644 index 0000000..c82fadb Binary files /dev/null and b/assets/offer_claimed.png differ diff --git a/assets/offer_dismiss.png b/assets/offer_dismiss.png new file mode 100644 index 0000000..ad89713 Binary files /dev/null and b/assets/offer_dismiss.png differ diff --git a/assets/offer_monthcard.png b/assets/offer_monthcard.png new file mode 100644 index 0000000..17e2507 Binary files /dev/null and b/assets/offer_monthcard.png differ diff --git a/assets/offer_page.png b/assets/offer_page.png new file mode 100644 index 0000000..43e9e31 Binary files /dev/null and b/assets/offer_page.png differ diff --git a/assets/offer_reward.png b/assets/offer_reward.png new file mode 100644 index 0000000..e614d39 Binary files /dev/null and b/assets/offer_reward.png differ diff --git a/automation/__init__.py b/automation/__init__.py new file mode 100644 index 0000000..f51da91 --- /dev/null +++ b/automation/__init__.py @@ -0,0 +1 @@ +"""Local, screenshot-verified game workflows.""" diff --git a/automation/app.py b/automation/app.py new file mode 100644 index 0000000..d5573da --- /dev/null +++ b/automation/app.py @@ -0,0 +1,170 @@ +import json +import queue +import threading +import tkinter as tk +from dataclasses import asdict +from pathlib import Path +from tkinter import filedialog, messagebox, ttk + +from PIL import Image, ImageTk + +from .engine import Desktop, Matcher, Region, Runner, load_flow + +ROOT = Path(__file__).resolve().parent.parent +LOCAL = ROOT / 'local' + + +class Selector(tk.Toplevel): + def __init__(self, parent, screenshot, on_select): + super().__init__(parent) + self.title('拖动框选游戏内容:不包含微信标题栏和窗口边框') + self.original = screenshot + self.on_select = on_select + ratio = min(1000 / screenshot.width, 700 / screenshot.height, 1) + self.preview_size = (round(screenshot.width * ratio), round(screenshot.height * ratio)) + self.photo = ImageTk.PhotoImage(screenshot.resize(self.preview_size)) + self.canvas = tk.Canvas(self, width=self.preview_size[0], height=self.preview_size[1], highlightthickness=0) + self.canvas.pack() + self.canvas.create_image(0, 0, image=self.photo, anchor='nw') + self.start = None + self.rect = None + self.canvas.bind('', self.press) + self.canvas.bind('', self.drag) + self.canvas.bind('', self.release) + + def press(self, event): + self.start = (event.x, event.y) + if self.rect: + self.canvas.delete(self.rect) + self.rect = self.canvas.create_rectangle(event.x, event.y, event.x, event.y, outline='#ff3333', width=3) + + def drag(self, event): + if self.start: + self.canvas.coords(self.rect, *self.start, event.x, event.y) + + def release(self, event): + if not self.start: + return + x1, x2 = sorted((self.start[0], event.x)) + y1, y2 = sorted((self.start[1], event.y)) + sx = self.original.width / self.preview_size[0] + sy = self.original.height / self.preview_size[1] + region = Region(round(x1 * sx), round(y1 * sy), round((x2 - x1) * sx), round((y2 - y1) * sy)) + try: + region.validate(self.original.size) + except Exception as exc: + messagebox.showerror('重新框选', str(exc), parent=self) + return + self.on_select(region) + self.destroy() + + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title('蔚蓝星球 · 本地自动化') + self.geometry('720x520') + self.region = None + self.events = queue.Queue() + self.stop = threading.Event() + self.busy = False + self.flow = load_flow(ROOT / 'workflows' / 'pig_claim.json') + self.matcher = Matcher(ROOT / 'assets') + self.status = tk.StringVar(value='请先将游戏放在主屏幕,打开首页并校准游戏内容区域。') + ttk.Label(self, text='第一条流程:小金猪挂机奖励领取', font=('', 18)).pack(pady=16) + ttk.Label(self, text='开始前请保持游戏窗口可见且位置不变。鼠标移到主屏幕左上角可紧急停止。').pack() + bar = ttk.Frame(self) + bar.pack(pady=14) + self.buttons = [] + for name, cmd in [('校准游戏区域', self.capture), ('导入截图校准', self.import_image), ('预览流程', lambda: self.launch(True)), ('执行领取', lambda: self.launch(False))]: + button = ttk.Button(bar, text=name, command=cmd) + button.pack(side='left', padx=4) + self.buttons.append(button) + ttk.Button(self, text='停止', command=self.stop.set).pack() + ttk.Label(self, textvariable=self.status, wraplength=680).pack(pady=10) + self.output = tk.Text(self, height=16, wrap='word', state='disabled') + self.output.pack(fill='both', expand=True, padx=16, pady=12) + self.protocol('WM_DELETE_WINDOW', self.close) + config = LOCAL / 'region.json' + if config.exists(): + try: + self.region = Region(**json.loads(config.read_text())) + self.status.set('已加载游戏区域;移动游戏窗口或更换屏幕后请重新校准。') + except (ValueError, TypeError): + pass + self.after(100, self.poll) + + def log(self, text): + self.events.put(('log', text)) + + def set_region(self, region): + LOCAL.mkdir(exist_ok=True) + (LOCAL / 'region.json').write_text(json.dumps(asdict(region)), encoding='utf-8') + self.region = region + self.status.set('校准完成。先预览,确认首页识别通过后再执行。') + + def capture(self): + self.iconify() + self.after(1500, self.take_capture) + + def take_capture(self): + try: + screenshot = Desktop().screenshot() + except Exception as exc: + self.deiconify() + messagebox.showerror('截图失败', '请检查系统屏幕录制权限。\n' + str(exc)) + return + self.deiconify() + Selector(self, screenshot, self.set_region) + + def import_image(self): + name = filedialog.askopenfilename(filetypes=[('主屏幕完整截图', '*.png *.jpg *.jpeg')]) + if name: + with Image.open(name) as image: + Selector(self, image.convert('RGB').copy(), self.set_region) + + def launch(self, dry_run): + if self.busy: + return + if self.region is None: + messagebox.showinfo('需要校准', '请先校准游戏内容区域。') + return + self.busy = True + self.stop.clear() + for button in self.buttons: + button.configure(state='disabled') + self.status.set('正在预览…' if dry_run else '3 秒后执行,请将游戏放到前台。') + region = self.region + def work(): + try: + if self.stop.wait(3): + return + Runner(Desktop(), self.matcher, region, self.log, self.stop).run(self.flow, dry_run) + except Exception as exc: + self.log('流程停止:' + str(exc)) + finally: + self.events.put(('done', None)) + threading.Thread(target=work, daemon=True).start() + + def poll(self): + while not self.events.empty(): + kind, value = self.events.get_nowait() + if kind == 'log': + self.output.configure(state='normal') + self.output.insert('end', value + '\n') + self.output.see('end') + self.output.configure(state='disabled') + elif kind == 'done': + self.busy = False + for button in self.buttons: + button.configure(state='normal') + self.status.set('本轮已结束,请查看日志中的实际结果。') + self.after(100, self.poll) + + def close(self): + self.stop.set() + self.destroy() + + +def main(): + App().mainloop() diff --git a/automation/engine.py b/automation/engine.py new file mode 100644 index 0000000..4b1f1ab --- /dev/null +++ b/automation/engine.py @@ -0,0 +1,169 @@ +import json +import math +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + + +class WorkflowError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Region: + # Coordinates in primary-screen screenshot pixels, not mouse points. + x: int + y: int + width: int + height: int + + def validate(self, screen_size): + if min(self.x, self.y) < 0 or min(self.width, self.height) <= 0: + raise WorkflowError('请先框选有效的游戏内容区域。') + if self.x + self.width > screen_size[0] or self.y + self.height > screen_size[1]: + raise WorkflowError('屏幕尺寸已变化,请重新校准游戏区域。') + if abs(self.width / self.height - 562 / 1002) > 0.035: + raise WorkflowError('区域比例不符合竖屏游戏,请只框选游戏内容,不包含标题栏。') + + def mouse_point(self, point, screenshot_size, mouse_size): + if len(point) != 2 or not all(math.isfinite(v) and 0 < v < 1 for v in point): + raise WorkflowError('点击位置必须位于游戏区域内部。') + self.validate(screenshot_size) + return ( + round((self.x + point[0] * self.width) * mouse_size[0] / screenshot_size[0]), + round((self.y + point[1] * self.height) * mouse_size[1] / screenshot_size[1]), + ) + + +class Matcher: + def __init__(self, assets): + self.assets = Path(assets) + self.cache = {} + + def frame(self, screenshot, region): + region.validate(screenshot.size) + crop = screenshot.crop((region.x, region.y, region.x + region.width, region.y + region.height)) + return np.asarray(crop.convert('RGB').resize((562, 1002), Image.Resampling.LANCZOS)) + + def score(self, frame, spec): + name = spec['template'] + if name not in self.cache: + p = self.assets / name + if not p.is_file(): + raise WorkflowError('缺少识别模板:' + name) + self.cache[name] = np.asarray(Image.open(p).convert('RGB')) + template = self.cache[name] + x1, y1, x2, y2 = spec['search'] + area = frame[y1:y2, x1:x2] + h, w = template.shape[:2] + if area.shape[0] < h or area.shape[1] < w: + raise WorkflowError('模板尺寸超出识别区域。') + result = cv2.matchTemplate(area, template, cv2.TM_CCOEFF_NORMED) + _, score, _, (x, y) = cv2.minMaxLoc(result) + candidate = area[y:y + h, x:x + w] + # Correlation alone can mistake a darkened background behind a popup + # for the active home page. Also require similar absolute colors. + difference = float(np.abs(candidate.astype(float) - template.astype(float)).mean()) + if difference > spec.get('max_color_difference', 28): + return 0.0 + return float(score) if math.isfinite(score) else 0.0 + + def matches(self, frame, specs): + return all(self.score(frame, s) >= s.get('threshold', 0.86) for s in specs) + + +class Desktop: + def __init__(self): + import pyautogui + self.api = pyautogui + self.api.FAILSAFE = True + self.api.PAUSE = 0.1 + + def screenshot(self): + return self.api.screenshot() + + def click(self, point, region, screenshot_size): + from .permissions import mouse_permission_granted + if not mouse_permission_granted(): + raise WorkflowError( + 'macOS 辅助功能权限未授予,无法发送有效鼠标点击。' + '请在系统设置 → 隐私与安全性 → 辅助功能中启用运行程序的宿主' + '(如 ChatGPT/Codex 或终端),重启宿主后再测试。' + ) + x, y = region.mouse_point(point, screenshot_size, tuple(self.api.size())) + self.api.moveTo(x, y, duration=0.2) + self.api.mouseDown() + try: + # The game did not react to instant down/up in the live test. + time.sleep(0.2) + finally: + # Always release, including if the pointer moved to a failsafe + # corner during the hold. Restore the guard immediately afterward. + saved_failsafe = self.api.FAILSAFE + self.api.FAILSAFE = False + try: + self.api.mouseUp() + finally: + self.api.FAILSAFE = saved_failsafe + + +class Runner: + def __init__(self, desktop, matcher, region, log=print, stop=None): + self.desktop = desktop + self.matcher = matcher + self.region = region + self.log = log + self.stop = stop or threading.Event() + + def check_stop(self): + if self.stop.is_set(): + raise WorkflowError('已停止。') + + def wait_state(self, specs, timeout=10, absent=False): + end = time.monotonic() + timeout + stable = 0 + while time.monotonic() < end: + self.check_stop() + screenshot = self.desktop.screenshot() + frame = self.matcher.frame(screenshot, self.region) + matched = self.matcher.matches(frame, specs) + stable = stable + 1 if matched != absent else 0 + if stable >= 2: + return screenshot + self.stop.wait(0.35) + raise WorkflowError('等待页面状态超时,流程停止,请检查当前界面。') + + def run(self, flow, dry_run=False): + self.check_stop() + # A dry run validates the CURRENT page and lists later actions; + # it cannot verify pages that have not actually been opened. + initial = self.wait_state(flow['steps'][0]['expect']) + if dry_run: + for step in flow['steps']: + self.log('计划:' + step['name']) + self.log('预览完成:首页识别通过,后续页面尚未验证;未执行点击。') + return + for step in flow['steps']: + self.log('检查:' + step['name']) + screenshot = self.wait_state(step['expect'], step.get('timeout', 10)) + self.check_stop() + if 'click' in step: + # One click only; never retry a claim after an uncertain result. + self.desktop.click(step['click'], self.region, screenshot.size) + self.log('已点击:' + step['name']) + if 'wait_absent' in step: + self.wait_state(step['wait_absent'], step.get('timeout', 10), absent=True) + if step.get('settle'): + self.stop.wait(step['settle']) + self.check_stop() + self.wait_state(flow['finish']) + self.log(flow['result']) + + +def load_flow(path): + return json.loads(Path(path).read_text(encoding='utf-8')) diff --git a/automation/offers.py b/automation/offers.py new file mode 100644 index 0000000..e9ff50b --- /dev/null +++ b/automation/offers.py @@ -0,0 +1,111 @@ +"""Verified offer-claim workflow, with an explicit already-claimed branch.""" +import time + +from .engine import Desktop, Region, WorkflowError +from .permissions import mouse_permission_granted + + +class MacGameDesktop(Desktop): + def window_bounds(self): + import Quartz + windows = Quartz.CGWindowListCopyWindowInfo( + Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) + matches = [w for w in windows + if w.get('kCGWindowName') == '永远的蔚蓝星球' + and w.get('kCGWindowOwnerName') == '微信'] + if len(matches) != 1: + raise WorkflowError('请在微信中正常打开一个游戏窗口,不要直接启动 WeApp。') + return dict(matches[0]['kCGWindowBounds']) + + def region_for(self, screenshot): + b = self.window_bounds() + width, height = self.api.size() + sx, sy = screenshot.width / width, screenshot.height / height + # This observed WeChat header is 44 logical points high. Expanded + # sidebars change aspect ratio and are rejected by Region.validate. + region = Region(round(b['X'] * sx), round((b['Y'] + 44) * sy), + round(b['Width'] * sx), round((b['Height'] - 44) * sy)) + region.validate(screenshot.size) + return region + + def activate(self): + if not mouse_permission_granted(): + raise WorkflowError('请先为运行宿主授予 macOS 辅助功能权限。') + b = self.window_bounds() + self.region_for(self.screenshot()) + # Focus with a click in the observed empty part of the header, so + # the first game-button click is not consumed by window activation. + self.api.moveTo(b['X'] + 80, b['Y'] + 22, duration=0.2) + self.api.mouseDown() + try: + time.sleep(0.2) + finally: + saved = self.api.FAILSAFE + self.api.FAILSAFE = False + try: + self.api.mouseUp() + finally: + self.api.FAILSAFE = saved + time.sleep(0.3) + + +class OfferRunner: + def __init__(self, desktop, matcher, flow, region_provider, log=print, timeout=12): + self.desktop = desktop + self.matcher = matcher + self.flow = flow + self.region_provider = region_provider + self.log = log + self.timeout = timeout + + def wait(self, *names): + deadline = time.monotonic() + self.timeout + previous, count = None, 0 + while time.monotonic() < deadline: + screenshot = self.desktop.screenshot() + region = self.region_provider(screenshot) + frame = self.matcher.frame(screenshot, region) + detected = next((name for name in names + if self.matcher.matches(frame, self.flow['states'][name])), None) + count = count + 1 if detected and detected == previous else 1 if detected else 0 + previous = detected + if count >= 2: + return detected, screenshot, region + time.sleep(0.25) + raise WorkflowError('未识别到预期页面 ' + '/'.join(names) + ',已停止,不重复领取。') + + def click(self, action, observation): + _, screenshot, region = observation + # Stop on movement after the recognition frame instead of clicking + # stale coordinates. A future run can re-locate the moved window. + if self.region_provider(screenshot) != region: + raise WorkflowError('识别后游戏窗口移动,已停止,请重新运行。') + self.desktop.click(self.flow['clicks'][action], region, screenshot.size) + self.log('已执行:' + action) + + def run(self, dry_run=False): + home = self.wait('home') + if dry_run: + self.log('首页识别通过。计划:特惠 → 一键领取 → 关闭奖励 → 验证按钮变灰 → 返回首页。未点击。') + return 'preview' + self.click('open', home) + offer = self.wait('available', 'claimed', 'menu') + if offer[0] == 'menu': + self.click('battlepass', offer) + offer = self.wait('available', 'claimed') + if offer[0] == 'claimed': + self.log('一键领取已变灰,当前没有可领取奖励;跳过领取。') + self.click('back', offer) + self.wait('home') + self.log('已确认返回首页。') + return 'already_claimed' + self.click('claim', offer) + reward = self.wait('reward') + self.log('已确认“恭喜获得”奖励弹框。') + self.click('dismiss', reward) + completed = self.wait('claimed') + self.log('已确认一键领取按钮变灰。') + self.click('back', completed) + self.wait('home') + self.log('领取完成,已确认返回首页。') + return 'claimed' diff --git a/automation/permissions.py b/automation/permissions.py new file mode 100644 index 0000000..6cfce3c --- /dev/null +++ b/automation/permissions.py @@ -0,0 +1,15 @@ +"""Read-only permission checks; never open prompts or change system settings.""" +import ctypes +import sys + + +def mouse_permission_granted(): + if sys.platform != 'darwin': + return True + framework = ctypes.CDLL('/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices') + framework.AXIsProcessTrusted.restype = ctypes.c_bool + return bool(framework.AXIsProcessTrusted()) + + +if __name__ == '__main__': + print('辅助功能权限已授予' if mouse_permission_granted() else '辅助功能权限未授予') diff --git a/claim_offer.py b/claim_offer.py new file mode 100644 index 0000000..c9317db --- /dev/null +++ b/claim_offer.py @@ -0,0 +1,51 @@ +"""Run from the project root: python claim_offer.py [--dry-run].""" +import argparse +import json +import sys +import time +from pathlib import Path + +from automation.engine import Desktop, Matcher, Region, WorkflowError, load_flow +from automation.offers import MacGameDesktop, OfferRunner + +ROOT = Path(__file__).resolve().parent + + +def main(): + parser = argparse.ArgumentParser(description='首页 → 特惠战令 → 一键领取 → 返回首页') + parser.add_argument('--dry-run', action='store_true', help='只验证首页并显示计划,不点击') + parser.add_argument('--region', nargs=4, type=int, metavar=('X', 'Y', 'WIDTH', 'HEIGHT'), + help='游戏内容区域在主屏幕截图中的像素坐标,不含标题栏') + args = parser.parse_args() + try: + if sys.platform == 'darwin' and not args.region: + desktop = MacGameDesktop() + provider = desktop.region_for + if not args.dry_run: + desktop.activate() + else: + desktop = Desktop() + if args.region: + region = Region(*args.region) + else: + config = ROOT / 'local/region.json' + if not config.is_file(): + raise WorkflowError('请先用 main.py 校准游戏区域,或提供 --region。') + region = Region(**json.loads(config.read_text(encoding='utf-8'))) + provider = lambda screenshot: region + print('3 秒后开始;请保持游戏在前台。鼠标移到主屏幕左上角可紧急停止。', flush=True) + time.sleep(3) + runner = OfferRunner(desktop, Matcher(ROOT / 'assets'), + load_flow(ROOT / 'workflows/offer_claim.json'), provider) + runner.run(args.dry_run) + return 0 + except KeyboardInterrupt: + print('已停止。', file=sys.stderr) + return 130 + except Exception as exc: + print('流程停止:' + str(exc), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/docs/261004_1607-首页功能与日常任务梳理.md b/docs/261004_1607-首页功能与日常任务梳理.md index 46075f0..9264047 100644 --- a/docs/261004_1607-首页功能与日常任务梳理.md +++ b/docs/261004_1607-首页功能与日常任务梳理.md @@ -1,7 +1,7 @@ --- created_at: 2026-10-04 16:07:56 CST type: 永远的蔚蓝星球首页、商城、领地与公会入口、日常任务与自动化适配记录 -revision: 第5次 +revision: 第7次 revisions: - time: 2026-10-04 16:07:56 CST version: 第1次 @@ -23,18 +23,34 @@ revisions: version: 第5次 author: GPT-6 content: 补充公会八个入口及位置,右下角红色角色入口暂标名称待确认,记录用户确认各入口均有固定自动化流程。 -summary: 记录首页日常入口、商城六个区域、领地八个栏目及公会八个入口。商城关卡礼包与钻石暂不处理;领地和公会各入口均有用户确认的固定流程,具体步骤、消耗和完成判定待完善,公会右下角红色角色入口名称待确认。 + - time: 2026-10-04 16:47:04 CST + version: 第6次 + author: GPT-6 + content: 补充用户确认的入口红点规则,并记录先本地实现流程、验证后再打包为独立程序的开发顺序。 + - time: 2026-10-04 17:45:37 CST + version: 第7次 + author: GPT-6 + content: 实机完成特惠赏金战令一键领取及返回首页,新增脚本、奖励结果和已领取状态的识别逻辑。 +summary: 记录首页、商城、领地和公会的日常任务入口及待完善流程。入口红点表示内部有奖励可领取。特惠赏金战令一键领取已实机验证并写成脚本;开发先验证本地流程,再打包独立程序,商城关卡礼包和钻石暂不处理。 --- # 首页、商城、领地与公会功能及日常任务梳理 ## 记录范围与依据 -本记录针对《永远的蔚蓝星球》的首页、商城、领地与公会,依据用户对高级账号的说明、两张首页截图、八张商城截图、一张领地截图及一张公会截图。当前阶段先熟悉页面和任务流程,再决定自动化方案;尚未编写程序,也未验证各活动内部操作。 +本记录针对《永远的蔚蓝星球》的首页、商城、领地与公会,依据用户对高级账号的说明、两张首页截图、八张商城截图、一张领地截图及一张公会截图。当前已开始本地自动化原型开发;各活动内部流程继续逐项补充和实机验证。 主界面截图显示账号等级 95,当前关卡为“144. 寒霜之径”,底部导航包括商城、英雄、国王、领地、公会。这里描述的页面统一称为“首页”;底部“国王”是独立入口,其内部功能待后续梳理。此前低等级账号的功能锁定情况不代表当前账号。 -下文区分三类信息:**用户确认**的规律、**截图可见**的内容,以及**待验证**的内部流程。入口带红点只能作为检查线索,目前不能直接判定为可领取奖励。 +下文区分三类信息:**用户确认**的规律、**截图可见**的内容,以及**待验证**的内部流程。 + +## 通用规则与开发顺序 + +用户确认:**当某个按钮进入的页面内有奖励可以领取时,按钮右上角会出现红点。** 红点可作为进入该页面领取奖励的依据;进入后仍需识别具体奖励按钮与领取条件,领取成功通过内部状态变化确认。红点消失可以作为观察线索,但页面内仍可能存在其他奖励,不能只凭一次点击判定整页任务完成。没有红点时是否能跳过整页,需结合具体栏目流程确认。 + +用户确定的开发顺序:**先在本地实现部分自动化流程,逐项验证后再打包成独立程序**,最终目标仍是 macOS 与 Windows。模型 Key 由用户以后在程序设置中填写。 + +第一条本地原型流程为“小金猪 → 挂机奖励 → 领取 → 返回首页”,使用局部截图模板识别,执行前校准游戏区域,提供预览、日志和停止功能。当前不执行快速扫荡,也尚未加入红点检测或模型 API。奖励到账数量、领取后弹框和双平台操作需实机验证;超时停止,不重复领取。启动方法与验证命令见项目 README。 ## 首页 @@ -43,7 +59,7 @@ summary: 记录首页日常入口、商城六个区域、领地八个栏目及 | 入口 | 出现规律(用户确认) | 功能和日常逻辑(用户确认) | 后续需要核实 | | --- | --- | --- | --- | | 金沙游记 | 季节性大活动,不定期出现;每次名称可能不同;可有两个大活动同时出现,入口都在左上角 | 进入后包含多个分活动 | 分活动清单、各任务入口、签到与领奖步骤、完成标志 | -| 特惠 | 固定入口 | 内部有每天按固定流程领取的奖励 | 免费奖励位置、领取顺序、领取后状态、跨日重置规律 | +| 特惠 | 固定入口 | 内部有每天按固定流程领取的奖励;当前赏金战令一键领取已实机验证 | 其他子页面、跨日重置规律及活动更换后的样式 | | 活动 | 固定入口 | 内部有每天按固定流程领取的奖励 | 子页面结构、领取顺序、领取后状态、跨日重置规律 | | 返利 | 当前左侧存在,是否固定尚未说明 | 内部功能尚未说明 | 领取条件、是否与充值相关、是否存在日常免费内容 | | 观影 | 不定期出现 | 出现后流程固定,按按钮操作即可 | 按钮顺序、是否涉及广告播放、等待时间、领奖和结束状态 | @@ -53,6 +69,22 @@ summary: 记录首页日常入口、商城六个区域、领地八个栏目及 截图中左侧入口为纵向排列,较下方按钮部分受折叠区域影响。后续需要确认列表的展开、收起或滚动方式,不能假定所有入口始终同时可见或保持同一位置。 +### 特惠战令一键领取(已实机验证) + +2026-10-04 在当前账号实际执行并观察到完整流程:**首页 → 左侧特惠 → 赏金战令 → 一键领取 → 恭喜获得弹框 → 点击空白处关闭 → 一键领取按钮变灰、已领奖励出现绿色勾选 → 左下角返回 → 首页**。 + +对应脚本为项目根目录的 `claim_offer.py`,步骤与页面模板配置保存在 `workflows/offer_claim.json`。在项目目录运行: + +```bash +.venv/bin/python claim_offer.py +``` + +脚本连续两次识别预期页面后才点击。领取后必须识别奖励弹框和灰色领取按钮,最后确认首页。如果进入时按钮已经变灰,则跳过领取并返回首页;页面不匹配或超时会停止,不重复尝试领取。可用 `--dry-run` 只验证首页并显示计划。 + +特惠也可能默认打开“超值月卡”;脚本识别该页后点击底部“战令”,再进入领取流程。完整脚本复跑已验证从月卡切换战令、识别已领取并返回首页,16 项自动化测试通过。 + +当前流程只覆盖本次“赏金战令”奖励,不购买付费战令,也不遍历其他特惠子页面。macOS 使用 PyAutoGUI 系统鼠标事件,读取微信游戏窗口位置,排除标题栏并处理 Retina 缩放;窗口在识别后移动会停止。Windows 的校准区域入口已提供,仍需在 Windows 实机验证。跨日刷新、活动更换和不同尺寸下的模板适配后续完善。 + ### 右侧与右上方入口 | 区域 | 入口 | 出现规律与用途 | 当前记录状态 | diff --git a/main.py b/main.py new file mode 100644 index 0000000..7de4f38 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from automation.app import main + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3068d0a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Pillow>=10.4,<12 +numpy>=1.26,<3 +opencv-python-headless>=4.10,<5 +PyAutoGUI==0.9.54 +pyobjc-core>=10,<12; sys_platform == "darwin" +pyobjc-framework-quartz>=10,<12; sys_platform == "darwin" diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..8a0adc1 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,126 @@ +import threading +import unittest +from unittest.mock import Mock, patch +from pathlib import Path + +import numpy as np +from PIL import Image + +from automation.engine import Desktop, Matcher, Region, Runner, WorkflowError, load_flow + +ROOT = Path(__file__).resolve().parent.parent + + +class FakeDesktop: + def __init__(self, images): + self.images = images + self.index = 0 + self.clicks = [] + + def screenshot(self): + return self.images[min(self.index, len(self.images) - 1)] + + def click(self, point, region, size): + self.clicks.append(point) + self.index += 1 + + +class EngineTests(unittest.TestCase): + def setUp(self): + self.matcher = Matcher(ROOT / 'assets') + self.flow = load_flow(ROOT / 'workflows/pig_claim.json') + self.region = Region(0, 0, 562, 1002) + + def state(self, specs): + image = Image.new('RGB', (562, 1002), (45, 62, 78)) + for spec in specs: + template = Image.open(ROOT / 'assets' / spec['template']).convert('RGB') + x, y, _, _ = spec['search'] + image.paste(template, (x + 5, y + 5)) + return image + + def test_retina_coordinate_mapping(self): + region = Region(400, 100, 562, 1002) + self.assertEqual(region.mouse_point((0.5, 0.5), (2000, 1400), (1000, 700)), (340, 300)) + + def test_invalid_region_and_click(self): + with self.assertRaises(WorkflowError): + Region(0, 0, 800, 400).validate((1000, 1000)) + with self.assertRaises(WorkflowError): + self.region.mouse_point((1.5, 0.5), (562, 1002), (562, 1002)) + + def test_home_not_detected_behind_dark_popup(self): + spec = self.flow['finish'] + frame = self.matcher.frame(self.state(spec), self.region) + self.assertTrue(self.matcher.matches(frame, spec)) + self.assertFalse(self.matcher.matches((frame * 0.45).astype(np.uint8), spec)) + + def test_claim_workflow_and_no_sweep(self): + home = self.state(self.flow['finish']) + modal = self.state(self.flow['steps'][1]['expect']) + outcome = Image.new('RGB', (562, 1002), (80, 90, 120)) + desktop = FakeDesktop([home, modal, outcome, home]) + logs = [] + Runner(desktop, self.matcher, self.region, logs.append).run(self.flow) + self.assertEqual(desktop.clicks, [[0.135, 0.607], [0.747, 0.738], [0.5, 0.875]]) + self.assertIn('到账数量尚未验证', logs[-1]) + + def test_preview_does_not_click(self): + desktop = FakeDesktop([self.state(self.flow['finish'])]) + Runner(desktop, self.matcher, self.region, lambda _: None).run(self.flow, True) + self.assertEqual(desktop.clicks, []) + + def test_unchanged_claim_stops_without_retry(self): + home = self.state(self.flow['finish']) + modal = self.state(self.flow['steps'][1]['expect']) + desktop = FakeDesktop([home, modal, modal]) + steps = [dict(step) for step in self.flow['steps']] + steps[1]['timeout'] = 0.8 + flow = dict(self.flow, steps=steps) + with self.assertRaises(WorkflowError): + Runner(desktop, self.matcher, self.region, lambda _: None).run(flow) + self.assertEqual(desktop.clicks, [[0.135, 0.607], [0.747, 0.738]]) + + def test_unrecognized_screen_stops_without_click(self): + desktop = FakeDesktop([Image.new('RGB', (562, 1002))]) + flow = dict(self.flow, steps=[dict(self.flow['steps'][0], timeout=0.05)]) + runner = Runner(desktop, self.matcher, self.region, lambda _: None) + # Directly check the same initial guard with a short timeout. + with self.assertRaises(WorkflowError): + runner.wait_state(flow['steps'][0]['expect'], timeout=0.05) + self.assertEqual(desktop.clicks, []) + + def test_stop_before_actions(self): + stop = threading.Event() + stop.set() + desktop = FakeDesktop([]) + with self.assertRaises(WorkflowError): + Runner(desktop, self.matcher, self.region, stop=stop).run(self.flow) + self.assertEqual(desktop.clicks, []) + + def test_permission_denied_blocks_mouse_event(self): + desktop = Desktop.__new__(Desktop) + desktop.api = Mock() + with patch('automation.permissions.mouse_permission_granted', return_value=False): + with self.assertRaisesRegex(WorkflowError, '辅助功能权限未授予'): + desktop.click((0.5, 0.5), self.region, (562, 1002)) + desktop.api.click.assert_not_called() + desktop.api.mouseDown.assert_not_called() + + def test_held_click_releases_after_interruption(self): + desktop = Desktop.__new__(Desktop) + desktop.api = Mock() + desktop.api.size.return_value = (562, 1002) + desktop.api.FAILSAFE = True + with patch('automation.permissions.mouse_permission_granted', return_value=True): + with patch('automation.engine.time.sleep', side_effect=RuntimeError('interrupted')): + with self.assertRaises(RuntimeError): + desktop.click((0.5, 0.5), self.region, (562, 1002)) + desktop.api.moveTo.assert_called_once_with(281, 501, duration=0.2) + desktop.api.mouseDown.assert_called_once() + desktop.api.mouseUp.assert_called_once() + self.assertTrue(desktop.api.FAILSAFE) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_offers.py b/tests/test_offers.py new file mode 100644 index 0000000..fb3d6c6 --- /dev/null +++ b/tests/test_offers.py @@ -0,0 +1,82 @@ +import unittest +from pathlib import Path + +from PIL import Image + +from automation.engine import Matcher, Region, WorkflowError, load_flow +from automation.offers import OfferRunner + +ROOT = Path(__file__).resolve().parent.parent + + +class OfferDesktop: + def __init__(self, images): + self.images = images + self.index = 0 + self.clicks = [] + + def screenshot(self): + return self.images[min(self.index, len(self.images) - 1)] + + def click(self, point, region, size): + self.clicks.append(point) + self.index += 1 + + +class OfferTests(unittest.TestCase): + def setUp(self): + self.flow = load_flow(ROOT / 'workflows/offer_claim.json') + self.matcher = Matcher(ROOT / 'assets') + self.region = Region(0, 0, 562, 1002) + + def image(self, name): + image = Image.new('RGB', (562, 1002), (45, 62, 78)) + for spec in self.flow['states'][name]: + template = Image.open(ROOT / 'assets' / spec['template']).convert('RGB') + x, y, _, _ = spec['search'] + image.paste(template, (x + 5, y + 5)) + return image + + def runner(self, names): + desktop = OfferDesktop([self.image(n) for n in names]) + runner = OfferRunner(desktop, self.matcher, self.flow, lambda _: self.region, + log=lambda _: None, timeout=0.8) + return desktop, runner + + def test_full_claim_and_return(self): + desktop, runner = self.runner(['home', 'available', 'reward', 'claimed', 'home']) + self.assertEqual(runner.run(), 'claimed') + self.assertEqual(desktop.clicks, [self.flow['clicks'][n] for n in ('open', 'claim', 'dismiss', 'back')]) + + def test_already_claimed_does_not_claim_again(self): + desktop, runner = self.runner(['home', 'claimed', 'home']) + self.assertEqual(runner.run(), 'already_claimed') + self.assertEqual(desktop.clicks, [self.flow['clicks'][n] for n in ('open', 'back')]) + + def test_monthcard_entry_selects_battlepass(self): + desktop, runner = self.runner(['home', 'menu', 'claimed', 'home']) + self.assertEqual(runner.run(), 'already_claimed') + self.assertEqual(desktop.clicks, [self.flow['clicks'][n] for n in ('open', 'battlepass', 'back')]) + + def test_missing_reward_stops_without_retry(self): + desktop, runner = self.runner(['home', 'available', 'available']) + with self.assertRaises(WorkflowError): + runner.run() + self.assertEqual(desktop.clicks, [self.flow['clicks'][n] for n in ('open', 'claim')]) + + def test_preview_never_clicks(self): + desktop, runner = self.runner(['home']) + self.assertEqual(runner.run(True), 'preview') + self.assertEqual(desktop.clicks, []) + + def test_window_movement_blocks_stale_click(self): + desktop, runner = self.runner(['home']) + observation = ('home', desktop.screenshot(), self.region) + runner.region_provider = lambda _: Region(2, 0, 562, 1002) + with self.assertRaises(WorkflowError): + runner.click('open', observation) + self.assertEqual(desktop.clicks, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/workflows/offer_claim.json b/workflows/offer_claim.json new file mode 100644 index 0000000..ddda936 --- /dev/null +++ b/workflows/offer_claim.json @@ -0,0 +1,128 @@ +{ + "name": "特惠战令一键领取", + "states": { + "home": [ + { + "template": "home.png", + "search": [ + 150, + 730, + 410, + 865 + ] + } + ], + "available": [ + { + "template": "offer_page.png", + "search": [ + 20, + 20, + 320, + 125 + ] + }, + { + "template": "offer_claim.png", + "search": [ + 20, + 795, + 195, + 885 + ] + } + ], + "claimed": [ + { + "template": "offer_page.png", + "search": [ + 20, + 20, + 320, + 125 + ] + }, + { + "template": "offer_claimed.png", + "search": [ + 10, + 790, + 190, + 892 + ] + }, + { + "template": "offer_back.png", + "search": [ + 15, + 905, + 125, + 985 + ] + } + ], + "reward": [ + { + "template": "offer_reward.png", + "search": [ + 130, + 235, + 440, + 355 + ], + "threshold": 0.8 + }, + { + "template": "offer_dismiss.png", + "search": [ + 160, + 625, + 410, + 705 + ] + } + ], + "menu": [ + { + "template": "offer_monthcard.png", + "search": [ + 150, + 0, + 430, + 110 + ] + }, + { + "template": "offer_back.png", + "search": [ + 15, + 905, + 125, + 985 + ] + } + ] + }, + "clicks": { + "open": [ + 0.08, + 0.233 + ], + "claim": [ + 0.178, + 0.839 + ], + "dismiss": [ + 0.5, + 0.664 + ], + "back": [ + 0.12, + 0.94 + ], + "battlepass": [ + 0.49, + 0.94 + ] + } +} diff --git a/workflows/pig_claim.json b/workflows/pig_claim.json new file mode 100644 index 0000000..3a5364f --- /dev/null +++ b/workflows/pig_claim.json @@ -0,0 +1,28 @@ +{ + "name": "小金猪挂机奖励领取", + "steps": [ + { + "name": "首页 → 打开小金猪", + "expect": [{"template": "home.png", "search": [150, 730, 410, 865]}], + "click": [0.135, 0.607] + }, + { + "name": "挂机奖励 → 领取(不执行快速扫荡)", + "expect": [ + {"template": "idle_title.png", "search": [170, 285, 390, 365]}, + {"template": "claim.png", "search": [300, 680, 535, 805]} + ], + "click": [0.747, 0.738], + "wait_absent": [{"template": "claim.png", "search": [300, 680, 535, 805]}], + "settle": 1.0 + }, + { + "name": "关闭奖励弹框并返回首页", + "expect": [], + "click": [0.5, 0.875], + "settle": 0.7 + } + ], + "finish": [{"template": "home.png", "search": [150, 730, 410, 865]}], + "result": "已点击领取,领取按钮状态已变化,并确认返回首页。奖励到账数量尚未验证,请首次实机运行时核对。" +}