feat: add offer reward claim workflow
This commit is contained in:
1 parent
870ca82e8a
commit
b6bd78dc66
25 files changed
+1028
-5
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Local, screenshot-verified game workflows."""
|
||||
@@ -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()
|
||||
@@ -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'))
|
||||
@@ -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'
|
||||
@@ -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 '辅助功能权限未授予')
|
||||
Reference in new issue
Block a user