feat: add offer reward claim workflow

This commit is contained in:
admin committed 2026-10-04 17:58:53 +08:00
1 parent 870ca82e8a
commit b6bd78dc66
25 files changed
+1028 -5

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Local, screenshot-verified game workflows."""
+170
View File
@@ -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('<ButtonPress-1>', self.press)
self.canvas.bind('<B1-Motion>', self.drag)
self.canvas.bind('<ButtonRelease-1>', 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()
+169
View File
@@ -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'))
+111
View File
@@ -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'
+15
View File
@@ -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 '辅助功能权限未授予')