改写战斗中替换group的逻辑
This commit is contained in:
commit
7f89eb0db8
3890 changed files with 82290 additions and 0 deletions
318
mower/utils/SecuritySm.py
Normal file
318
mower/utils/SecuritySm.py
Normal file
|
@ -0,0 +1,318 @@
|
|||
# from https://gitee.com/FancyCabbage/skyland-auto-sign
|
||||
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
|
||||
# 数美加密方法类
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.ciphers.algorithms import AES
|
||||
from cryptography.hazmat.primitives.ciphers.base import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers.modes import CBC, ECB
|
||||
|
||||
# 查询dId请求头
|
||||
devices_info_url = "https://fp-it.portal101.cn/deviceprofile/v4"
|
||||
|
||||
# 数美配置
|
||||
SM_CONFIG = {
|
||||
"organization": "UWXspnCCJN4sfYlNfqps",
|
||||
"appId": "default",
|
||||
"publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmxMNr7n8ZeT0tE1R9j/mPixoinPkeM+k4VGIn/s0k7N5rJAfnZ0eMER+QhwFvshzo0LNmeUkpR8uIlU/GEVr8mN28sKmwd2gpygqj0ePnBmOW4v0ZVwbSYK+izkhVFk2V/doLoMbWy6b+UnA8mkjvg0iYWRByfRsK2gdl7llqCwIDAQAB",
|
||||
"protocol": "https",
|
||||
"apiHost": "fp-it.portal101.cn",
|
||||
}
|
||||
|
||||
PK = serialization.load_der_public_key(base64.b64decode(SM_CONFIG["publicKey"]))
|
||||
|
||||
DES_RULE = {
|
||||
"appId": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "uy7mzc4h",
|
||||
"obfuscated_name": "xx",
|
||||
},
|
||||
"box": {"is_encrypt": 0, "obfuscated_name": "jf"},
|
||||
"canvas": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "snrn887t",
|
||||
"obfuscated_name": "yk",
|
||||
},
|
||||
"clientSize": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "cpmjjgsu",
|
||||
"obfuscated_name": "zx",
|
||||
},
|
||||
"organization": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "78moqjfc",
|
||||
"obfuscated_name": "dp",
|
||||
},
|
||||
"os": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "je6vk6t4",
|
||||
"obfuscated_name": "pj",
|
||||
},
|
||||
"platform": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "pakxhcd2",
|
||||
"obfuscated_name": "gm",
|
||||
},
|
||||
"plugins": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "v51m3pzl",
|
||||
"obfuscated_name": "kq",
|
||||
},
|
||||
"pmf": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "2mdeslu3",
|
||||
"obfuscated_name": "vw",
|
||||
},
|
||||
"protocol": {"is_encrypt": 0, "obfuscated_name": "protocol"},
|
||||
"referer": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "y7bmrjlc",
|
||||
"obfuscated_name": "ab",
|
||||
},
|
||||
"res": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "whxqm2a7",
|
||||
"obfuscated_name": "hf",
|
||||
},
|
||||
"rtype": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "x8o2h2bl",
|
||||
"obfuscated_name": "lo",
|
||||
},
|
||||
"sdkver": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "9q3dcxp2",
|
||||
"obfuscated_name": "sc",
|
||||
},
|
||||
"status": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "2jbrxxw4",
|
||||
"obfuscated_name": "an",
|
||||
},
|
||||
"subVersion": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "eo3i2puh",
|
||||
"obfuscated_name": "ns",
|
||||
},
|
||||
"svm": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "fzj3kaeh",
|
||||
"obfuscated_name": "qr",
|
||||
},
|
||||
"time": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "q2t3odsk",
|
||||
"obfuscated_name": "nb",
|
||||
},
|
||||
"timezone": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "1uv05lj5",
|
||||
"obfuscated_name": "as",
|
||||
},
|
||||
"tn": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "x9nzj1bp",
|
||||
"obfuscated_name": "py",
|
||||
},
|
||||
"trees": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "acfs0xo4",
|
||||
"obfuscated_name": "pi",
|
||||
},
|
||||
"ua": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "k92crp1t",
|
||||
"obfuscated_name": "bj",
|
||||
},
|
||||
"url": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "y95hjkoo",
|
||||
"obfuscated_name": "cf",
|
||||
},
|
||||
"version": {"is_encrypt": 0, "obfuscated_name": "version"},
|
||||
"vpw": {
|
||||
"cipher": "DES",
|
||||
"is_encrypt": 1,
|
||||
"key": "r9924ab5",
|
||||
"obfuscated_name": "ca",
|
||||
},
|
||||
}
|
||||
|
||||
BROWSER_ENV = {
|
||||
"plugins": "MicrosoftEdgePDFPluginPortableDocumentFormatinternal-pdf-viewer1,MicrosoftEdgePDFViewermhjfbmdgcfjbbpaeojofohoefgiehjai1",
|
||||
"ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.0.0",
|
||||
"canvas": "259ffe69", # 基于浏览器的canvas获得的值,不知道复用行不行
|
||||
"timezone": -480, # 时区,应该是固定值吧
|
||||
"platform": "Win32",
|
||||
"url": "https://www.skland.com/", # 固定值
|
||||
"referer": "",
|
||||
"res": "1920_1080_24_1.25", # 屏幕宽度_高度_色深_window.devicePixelRatio
|
||||
"clientSize": "0_0_1080_1920_1920_1080_1920_1080",
|
||||
"status": "0011", # 不知道在干啥
|
||||
}
|
||||
|
||||
|
||||
# // 将浏览器环境对象的key全部排序,然后对其所有的值及其子对象的值加入数字并字符串相加。若值为数字,则乘以10000(0x2710)再将其转成字符串存入数组,最后再做md5,存入tn变量(tn变量要做加密)
|
||||
# //把这个对象用加密规则进行加密,然后对结果做GZIP压缩(结果是对象,应该有序列化),最后做AES加密(加密细节目前不清除),密钥为变量priId
|
||||
# //加密规则:新对象的key使用相对应加解密规则的obfuscated_name值,value为字符串化后进行进行DES加密,再进行btoa加密
|
||||
|
||||
|
||||
# 通过测试
|
||||
def _DES(o: dict):
|
||||
result = {}
|
||||
for i in o.keys():
|
||||
if i in DES_RULE.keys():
|
||||
rule = DES_RULE[i]
|
||||
res = o[i]
|
||||
if rule["is_encrypt"] == 1:
|
||||
c = Cipher(TripleDES(rule["key"].encode("utf-8")), ECB())
|
||||
data = str(res).encode("utf-8")
|
||||
# 补足字节
|
||||
data += b"\x00" * 8
|
||||
res = base64.b64encode(c.encryptor().update(data)).decode("utf-8")
|
||||
result[rule["obfuscated_name"]] = res
|
||||
else:
|
||||
result[i] = o[i]
|
||||
return result
|
||||
|
||||
|
||||
# 通过测试
|
||||
def _AES(v: bytes, k: bytes):
|
||||
iv = "0102030405060708"
|
||||
key = AES(k)
|
||||
c = Cipher(key, CBC(iv.encode("utf-8")))
|
||||
c.encryptor()
|
||||
# 填充明文
|
||||
v += b"\x00"
|
||||
while len(v) % 16 != 0:
|
||||
v += b"\x00"
|
||||
return c.encryptor().update(v).hex()
|
||||
|
||||
|
||||
def GZIP(o: dict):
|
||||
# 这个压缩结果似乎和前台不太一样,不清楚是否会影响
|
||||
json_str = json.dumps(o, ensure_ascii=False)
|
||||
stream = gzip.compress(json_str.encode("utf-8"), 2, mtime=0)
|
||||
return base64.b64encode(stream)
|
||||
|
||||
|
||||
# 获得tn的值,后续做DES加密用
|
||||
# 通过测试
|
||||
def get_tn(o: dict):
|
||||
sorted_keys = sorted(o.keys())
|
||||
|
||||
result_list = []
|
||||
|
||||
for i in sorted_keys:
|
||||
v = o[i]
|
||||
if isinstance(v, (int, float)):
|
||||
v = str(v * 10000)
|
||||
elif isinstance(v, dict):
|
||||
v = get_tn(v)
|
||||
result_list.append(v)
|
||||
return "".join(result_list)
|
||||
|
||||
|
||||
def get_smid():
|
||||
t = time.localtime()
|
||||
_time = "{}{:0>2d}{:0>2d}{:0>2d}{:0>2d}{:0>2d}".format(
|
||||
t.tm_year, t.tm_mon, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec
|
||||
)
|
||||
uid = str(uuid.uuid4())
|
||||
v = _time + hashlib.md5(uid.encode("utf-8")).hexdigest() + "00"
|
||||
smsk_web = hashlib.md5(("smsk_web_" + v).encode("utf-8")).hexdigest()[0:14]
|
||||
return v + smsk_web + "0"
|
||||
|
||||
|
||||
def get_d_id():
|
||||
# storageName = '.thumbcache_' + md5(SM_CONFIG['organization']) // 用于从本地存储获得值
|
||||
# uid = uuid()
|
||||
# priId=md5(uid)[0:16]
|
||||
# ep=rsa(uid,publicKey)
|
||||
# SMID = localStorage.get(storageName);// 获得本地存储存的值
|
||||
# _0x30b2eb为递归md5
|
||||
|
||||
uid = str(uuid.uuid4()).encode("utf-8")
|
||||
priId = hashlib.md5(uid).hexdigest()[0:16]
|
||||
# ep不一定对,先走走看
|
||||
ep = PK.encrypt(uid, padding.PKCS1v15())
|
||||
ep = base64.b64encode(ep).decode("utf-8")
|
||||
|
||||
browser = BROWSER_ENV.copy()
|
||||
current_time = int(time.time() * 1000)
|
||||
browser.update(
|
||||
{
|
||||
"vpw": str(uuid.uuid4()),
|
||||
"svm": current_time,
|
||||
"trees": str(uuid.uuid4()),
|
||||
"pmf": current_time,
|
||||
}
|
||||
)
|
||||
|
||||
des_target = {
|
||||
**browser,
|
||||
"protocol": 102,
|
||||
"organization": SM_CONFIG["organization"],
|
||||
"appId": SM_CONFIG["appId"],
|
||||
"os": "web",
|
||||
"version": "3.0.0",
|
||||
"sdkver": "3.0.0",
|
||||
"box": "", # 似乎是个SMID,但是第一次的时候是空,不过不影响结果
|
||||
"rtype": "all",
|
||||
"smid": get_smid(),
|
||||
"subVersion": "1.0.0",
|
||||
"time": 0,
|
||||
}
|
||||
des_target["tn"] = hashlib.md5(get_tn(des_target).encode()).hexdigest()
|
||||
|
||||
des_result = _AES(GZIP(_DES(des_target)), priId.encode("utf-8"))
|
||||
|
||||
response = requests.post(
|
||||
devices_info_url,
|
||||
json={
|
||||
"appId": "default",
|
||||
"compress": 2,
|
||||
"data": des_result,
|
||||
"encode": 5,
|
||||
"ep": ep,
|
||||
"organization": SM_CONFIG["organization"],
|
||||
"os": "web", # 固定值
|
||||
},
|
||||
)
|
||||
|
||||
resp = response.json()
|
||||
if resp["code"] != 1100:
|
||||
raise Exception("did计算失败,请联系作者")
|
||||
# 开头必须是B
|
||||
return "B" + resp["detail"]["deviceId"]
|
0
mower/utils/__init__.py
Normal file
0
mower/utils/__init__.py
Normal file
157
mower/utils/character_recognize.py
Normal file
157
mower/utils/character_recognize.py
Normal file
|
@ -0,0 +1,157 @@
|
|||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
|
||||
import cv2
|
||||
|
||||
from mower.models import avatar, portrait
|
||||
from mower.utils import config
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.image import cropimg, thres2
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.matcher import GOOD_DISTANCE_LIMIT, flann, keypoints
|
||||
from mower.utils.vector import va
|
||||
|
||||
|
||||
def segment_room_select(img: tp.Image) -> list[tp.Scope]:
|
||||
"基建房间内选干员"
|
||||
line1 = cropimg(img, ((600, 519), (1920, 520)))
|
||||
hsv = cv2.cvtColor(line1, cv2.COLOR_RGB2HSV)
|
||||
mask = cv2.inRange(hsv, (98, 140, 200), (102, 255, 255))
|
||||
line1 = cv2.cvtColor(line1, cv2.COLOR_RGB2GRAY)
|
||||
line1[mask > 0] = (255,)
|
||||
line1 = thres2(line1, 140)
|
||||
|
||||
last_line = line1[-1]
|
||||
prev = last_line[0]
|
||||
start = None
|
||||
name_x = []
|
||||
for i in range(1, line1.shape[1]):
|
||||
curr = last_line[i]
|
||||
if prev == 0 and curr == 255 and start and i - start > 186:
|
||||
name_x.append((i + 415, i + 585))
|
||||
elif prev == 255 and curr == 0:
|
||||
start = i
|
||||
prev = curr
|
||||
|
||||
avatar_y = ((205, 320), (625, 740))
|
||||
avatar_p = []
|
||||
for x in name_x:
|
||||
for y in avatar_y:
|
||||
avatar_p.append(tuple(zip(x, y)))
|
||||
|
||||
logger.debug(avatar_p)
|
||||
return avatar_p
|
||||
|
||||
|
||||
def segment_team(img: tp.Image) -> list[tp.Scope]:
|
||||
"编队界面"
|
||||
# TODO: 利用img判断编队缺人的情况
|
||||
result = []
|
||||
for i in range(6):
|
||||
x = 283 + 232 * i
|
||||
for y in [204, 637]:
|
||||
result.append(((x, y), va((x, y), (180, 110))))
|
||||
logger.debug(result)
|
||||
return result
|
||||
|
||||
|
||||
def segment_team_select(img: tp.Image) -> list[tp.Scope]:
|
||||
"作战编队和训练位选干员"
|
||||
line1 = cropimg(img, ((600, 510), (1920, 511)))
|
||||
hsv = cv2.cvtColor(line1, cv2.COLOR_RGB2HSV)
|
||||
mask = cv2.inRange(hsv, (98, 140, 200), (102, 255, 255))
|
||||
line1 = cv2.cvtColor(line1, cv2.COLOR_RGB2GRAY)
|
||||
line1[mask > 0] = (255,)
|
||||
line1 = thres2(line1, 140)
|
||||
last_line = line1[-1]
|
||||
prev = last_line[0]
|
||||
start = None
|
||||
name_x = []
|
||||
for i in range(1, line1.shape[1]):
|
||||
curr = last_line[i]
|
||||
if prev == 0 and curr == 255 and start and i - start > 96:
|
||||
name_x.append((i + 415, i + 590))
|
||||
elif prev == 255 and curr == 0:
|
||||
start = i
|
||||
prev = curr
|
||||
|
||||
avatar_y = ((200, 310), (620, 725))
|
||||
avatar_p = []
|
||||
for x in name_x:
|
||||
for y in avatar_y:
|
||||
avatar_p.append(tuple(zip(x, y)))
|
||||
|
||||
logger.debug(avatar_p)
|
||||
return avatar_p
|
||||
|
||||
|
||||
p_list = []
|
||||
for i, img_list in portrait.items():
|
||||
p_list.extend(img_list)
|
||||
|
||||
a_list = []
|
||||
for i, img_list in avatar.items():
|
||||
a_list.extend(img_list)
|
||||
|
||||
|
||||
def match_operator(
|
||||
gray: tp.GrayImage, segment: list[tp.Scope], model: dict, d_list: list
|
||||
) -> tuple[tuple[str, tp.Scope]]:
|
||||
avatar_p = {}
|
||||
for av in segment:
|
||||
avatar_p[av] = None, 0
|
||||
|
||||
kp, des = keypoints(gray)
|
||||
|
||||
match = partial(flann.knnMatch, trainDescriptors=des, k=2)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=config.conf.max_workers) as executor:
|
||||
result = executor.map(match, d_list)
|
||||
|
||||
for i, img_list in model.items():
|
||||
for d1 in img_list:
|
||||
matches = next(result)
|
||||
good = {}
|
||||
for j in avatar_p:
|
||||
good[j] = []
|
||||
for pair in matches:
|
||||
if len(pair) != 2:
|
||||
continue
|
||||
x, y = pair
|
||||
if x.distance >= GOOD_DISTANCE_LIMIT * y.distance:
|
||||
continue
|
||||
for j in avatar_p:
|
||||
kpj = kp[x.trainIdx]
|
||||
if j[0][0] < kpj.pt[0] < j[1][0] and j[0][1] < kpj.pt[1] < j[1][1]:
|
||||
good[j].append(x)
|
||||
for j, g in good.items():
|
||||
score = len(g) / len(d1)
|
||||
if avatar_p[j][1] < score:
|
||||
avatar_p[j] = i, score
|
||||
|
||||
op_name = [p[0] for p in avatar_p.values()]
|
||||
logger.debug(op_name)
|
||||
|
||||
return tuple(zip(op_name, segment))
|
||||
|
||||
|
||||
match_portrait = partial(match_operator, model=portrait, d_list=p_list)
|
||||
match_avatar = partial(match_operator, model=avatar, d_list=a_list)
|
||||
|
||||
|
||||
def operator_room_select(img: tp.Image) -> tuple[tuple[str, tp.Scope]]:
|
||||
"基建房间内选干员"
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
return match_portrait(gray, segment_room_select(img))
|
||||
|
||||
|
||||
def operator_team(img: tp.Image) -> tuple[tuple[str, tp.Scope]]:
|
||||
"编队界面"
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
return match_portrait(gray, segment_team(img))
|
||||
|
||||
|
||||
def operator_team_select(img: tp.Image) -> tuple[tuple[str, tp.Scope]]:
|
||||
"作战编队和训练位选干员"
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
return match_portrait(gray, segment_team_select(img))
|
105
mower/utils/config/__init__.py
Normal file
105
mower/utils/config/__init__.py
Normal file
|
@ -0,0 +1,105 @@
|
|||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from queue import Queue
|
||||
from threading import Event
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import requests
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
from yamlcore import CoreDumper, CoreLoader
|
||||
|
||||
from mower.utils.config.conf import Conf
|
||||
from mower.utils.config.plan import PlanModel
|
||||
from mower.utils.path import get_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mower.utils.device.device import Device
|
||||
from mower.utils.recognize import Recognizer
|
||||
|
||||
conf_path = get_path("@app/conf.yml")
|
||||
plan_path = get_path("@app/plan.json")
|
||||
|
||||
|
||||
def save_conf():
|
||||
with conf_path.open("w", encoding="utf8") as f:
|
||||
yaml.dump(
|
||||
conf.model_dump(),
|
||||
f,
|
||||
Dumper=CoreDumper,
|
||||
encoding="utf-8",
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
|
||||
|
||||
def load_conf():
|
||||
global conf
|
||||
if not conf_path.is_file():
|
||||
conf_path.parent.mkdir(exist_ok=True)
|
||||
conf = Conf()
|
||||
save_conf()
|
||||
return
|
||||
with conf_path.open("r", encoding="utf-8") as f:
|
||||
conf = Conf(**yaml.load(f, Loader=CoreLoader))
|
||||
|
||||
|
||||
conf: Conf
|
||||
load_conf()
|
||||
|
||||
|
||||
def save_plan():
|
||||
with plan_path.open("w", encoding="utf-8") as f:
|
||||
json.dump(plan.model_dump(exclude_none=True), f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def load_plan():
|
||||
global plan
|
||||
if not plan_path.is_file():
|
||||
plan_path.parent.mkdir(exist_ok=True)
|
||||
plan = PlanModel()
|
||||
save_plan()
|
||||
return
|
||||
with plan_path.open("r", encoding="utf-8") as f:
|
||||
plan = PlanModel(**json.load(f))
|
||||
|
||||
|
||||
plan: PlanModel
|
||||
load_plan()
|
||||
|
||||
|
||||
stop_mower = Event()
|
||||
stop_maa = Event()
|
||||
|
||||
idle: bool = True
|
||||
|
||||
operators = {}
|
||||
|
||||
# 日志
|
||||
log_queue = Queue()
|
||||
wh = None
|
||||
|
||||
|
||||
class DroidCast(BaseModel):
|
||||
session: Any = requests.Session()
|
||||
port: int = 0
|
||||
process: Any = None
|
||||
|
||||
|
||||
droidcast = DroidCast()
|
||||
|
||||
screenshot_time: datetime = datetime.now() - timedelta(
|
||||
milliseconds=conf.screenshot_interval
|
||||
)
|
||||
screenshot_avg: Optional[int] = None
|
||||
screenshot_count: int = 0
|
||||
|
||||
device: Optional["Device"] = None
|
||||
recog: Optional["Recognizer"] = None
|
||||
|
||||
|
||||
# 常量
|
||||
APP_ACTIVITY_NAME = "com.u8.sdk.U8UnityContext"
|
||||
MAX_RETRYTIME = 5
|
||||
MNT_COMPATIBILITY_MODE = False
|
||||
MNT_PORT = 20937
|
573
mower/utils/config/conf.py
Normal file
573
mower/utils/config/conf.py
Normal file
|
@ -0,0 +1,573 @@
|
|||
from pydantic import BaseModel, model_validator
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
|
||||
class ConfModel(BaseModel):
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def nested_defaults(cls, data):
|
||||
for name, field in cls.model_fields.items():
|
||||
if name not in data:
|
||||
if field.default is PydanticUndefined:
|
||||
data[name] = field.annotation()
|
||||
else:
|
||||
data[name] = field.default
|
||||
return data
|
||||
|
||||
|
||||
class CluePart(ConfModel):
|
||||
class CreditFightConf(ConfModel):
|
||||
direction: str = "Right"
|
||||
"部署方向"
|
||||
operator: str = "风笛"
|
||||
"使用干员"
|
||||
squad: int = 1
|
||||
"编队序号"
|
||||
x: int = 5
|
||||
"横坐标"
|
||||
y: int = 3
|
||||
"纵坐标"
|
||||
|
||||
maa_credit_fight: bool = True
|
||||
"信用作战开关"
|
||||
credit_fight: CreditFightConf
|
||||
"信用作战设置"
|
||||
enable_party: int = 1
|
||||
"线索收集"
|
||||
leifeng_mode: int = 1
|
||||
"雷锋模式"
|
||||
maa_mall_blacklist: str = "加急许可,碳,碳素,家具零件"
|
||||
"黑名单"
|
||||
maa_mall_buy: str = "招聘许可,技巧概要·卷2"
|
||||
"优先购买"
|
||||
maa_mall_ignore_blacklist_when_full: bool = False
|
||||
"信用溢出时无视黑名单"
|
||||
|
||||
|
||||
class EmailPart(ConfModel):
|
||||
class CustomSMTPServerConf(ConfModel):
|
||||
enable: bool = False
|
||||
"启用自定义邮箱"
|
||||
server: str = ""
|
||||
"SMTP服务器"
|
||||
encryption: str = "starttls"
|
||||
"加密方式"
|
||||
ssl_port: int = 587
|
||||
"端口号"
|
||||
|
||||
mail_enable: int = 0
|
||||
"邮件提醒"
|
||||
account: str = ""
|
||||
"邮箱用户名"
|
||||
pass_code: str = ""
|
||||
"邮箱密码"
|
||||
recipient: list[str] = []
|
||||
"收件人"
|
||||
custom_smtp_server: CustomSMTPServerConf
|
||||
"自定义邮箱"
|
||||
mail_subject: str = "[Mower通知]"
|
||||
"标题前缀"
|
||||
notification_level: str = "INFO"
|
||||
"邮件通知等级"
|
||||
|
||||
|
||||
class ExtraPart(ConfModel):
|
||||
class WebViewConf(ConfModel):
|
||||
port: int = 58000
|
||||
"端口号"
|
||||
width: int = 1450
|
||||
"窗口宽度"
|
||||
height: int = 850
|
||||
"窗口高度"
|
||||
token: str = ""
|
||||
"远程连接密钥"
|
||||
scale: float = 1
|
||||
"网页缩放"
|
||||
tray: bool = True
|
||||
"托盘图标"
|
||||
|
||||
class WaitingSceneConf(ConfModel):
|
||||
CONNECTING: tuple[int, int] = (1000, 15)
|
||||
UNKNOWN: tuple[int, int] = (0, 10)
|
||||
UNKNOWN_WITH_NAVBAR: tuple[int, int] = (0, 5)
|
||||
LOADING: tuple[int, int] = (3000, 60)
|
||||
LOGIN_LOADING: tuple[int, int] = (3000, 30)
|
||||
LOGIN_MAIN_NOENTRY: tuple[int, int] = (3000, 30)
|
||||
OPERATOR_ONGOING: tuple[int, int] = (10000, 300)
|
||||
|
||||
start_automatically: bool = False
|
||||
"启动后自动开始任务"
|
||||
webview: WebViewConf
|
||||
"GUI相关设置"
|
||||
theme: str = "light"
|
||||
"界面主题"
|
||||
screenshot_interval: int = 500
|
||||
"截图最短间隔(毫秒)"
|
||||
screenshot: float = 24
|
||||
"截图保留时长(小时)"
|
||||
waiting_scene_v2: WaitingSceneConf
|
||||
"等待时间"
|
||||
max_workers: int = 4
|
||||
"头像、半身像识别线程数"
|
||||
|
||||
|
||||
class LongTaskPart(ConfModel):
|
||||
class RogueConf(ConfModel):
|
||||
squad: str = ""
|
||||
"分队"
|
||||
roles: str = ""
|
||||
"职业"
|
||||
core_char: str = ""
|
||||
"干员"
|
||||
use_support: bool = False
|
||||
"开局干员使用助战"
|
||||
use_nonfriend_support: bool = False
|
||||
"开局干员使用非好友助战"
|
||||
mode: int = 1
|
||||
"策略"
|
||||
refresh_trader_with_dice: bool = False
|
||||
"刷新商店(指路鳞)"
|
||||
expected_collapsal_paradigms: list[str] = [
|
||||
"目空一些",
|
||||
"睁眼瞎",
|
||||
"图像损坏",
|
||||
"一抹黑",
|
||||
]
|
||||
"需要刷的坍缩范式"
|
||||
|
||||
class ReclamationAlgorithmConf(ConfModel):
|
||||
timeout: int = 30
|
||||
"生息演算和隐秘战线的超时时间"
|
||||
|
||||
class SecretFrontConf(ConfModel):
|
||||
target: str = "结局A"
|
||||
"隐秘战线结局"
|
||||
|
||||
class SignInConf(ConfModel):
|
||||
enable: bool = True
|
||||
"签到活动开关"
|
||||
|
||||
maa_rg_enable: int = 0
|
||||
"大型任务"
|
||||
maa_long_task_type: str = "rogue"
|
||||
"大型任务类型"
|
||||
maa_rg_sleep_max: str = "0:00"
|
||||
"开始时间"
|
||||
maa_rg_sleep_min: str = "0:00"
|
||||
"停止时间"
|
||||
maa_rg_theme: str = "Mizuki"
|
||||
"肉鸽主题"
|
||||
rogue: RogueConf
|
||||
"肉鸽设置"
|
||||
reclamation_algorithm: ReclamationAlgorithmConf
|
||||
"生息演算"
|
||||
secret_front: SecretFrontConf
|
||||
"隐秘战线结局"
|
||||
sign_in: SignInConf
|
||||
"签到活动"
|
||||
|
||||
|
||||
class MaaPart(ConfModel):
|
||||
maa_path: str = "D:\\MAA-v4.13.0-win-x64"
|
||||
maa_conn_preset: str = "General"
|
||||
maa_touch_option: str = "maatouch"
|
||||
|
||||
|
||||
class RecruitPart(ConfModel):
|
||||
recruit_enable: bool = True
|
||||
"公招开关"
|
||||
recruit_robot: bool = True
|
||||
"保留支援机械标签"
|
||||
recruitment_permit: int = 30
|
||||
"三星招募阈值"
|
||||
recruit_gap: float = 9
|
||||
"启动间隔"
|
||||
recruit_auto_5: int = 1
|
||||
"五星招募策略,1自动,2手动"
|
||||
recruit_auto_only5: bool = False
|
||||
"五星词条组合唯一时自动选择"
|
||||
|
||||
|
||||
class RegularTaskPart(ConfModel):
|
||||
class MaaDailyPlan(BaseModel):
|
||||
medicine: int = 0
|
||||
stage: list[str]
|
||||
weekday: str
|
||||
|
||||
class MaaDailyPlan1(BaseModel):
|
||||
stage: str | list[str]
|
||||
周一: int
|
||||
周二: int
|
||||
周三: int
|
||||
周四: int
|
||||
周五: int
|
||||
周六: int
|
||||
周日: int
|
||||
|
||||
check_mail_enable: bool = True
|
||||
"领取邮件奖励"
|
||||
maa_enable: bool = True
|
||||
"日常任务"
|
||||
maa_gap: float = 3
|
||||
"日常任务间隔"
|
||||
maa_expiring_medicine: bool = True
|
||||
"自动使用将要过期(约3天)的理智药"
|
||||
exipring_medicine_on_weekend: bool = False
|
||||
"仅在周末使用将要过期的理智药"
|
||||
maa_eat_stone: bool = False
|
||||
"无限吃源石"
|
||||
maa_weekly_plan: list[MaaDailyPlan] = [
|
||||
{"medicine": 0, "stage": [""], "weekday": "周一"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周二"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周三"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周四"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周五"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周六"},
|
||||
{"medicine": 0, "stage": [""], "weekday": "周日"},
|
||||
]
|
||||
"周计划"
|
||||
maa_weekly_plan1: list[MaaDailyPlan1] = [
|
||||
{
|
||||
"stage": ["点x删除"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["把鼠标放到问号上查看帮助"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["自定义关卡3"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["Annihilation"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["1-7"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["LS-6"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["CE-6"],
|
||||
"周一": 0,
|
||||
"周二": 1,
|
||||
"周三": 0,
|
||||
"周四": 1,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["AP-5"],
|
||||
"周一": 1,
|
||||
"周二": 0,
|
||||
"周三": 0,
|
||||
"周四": 1,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["SK-6"],
|
||||
"周一": 1,
|
||||
"周二": 0,
|
||||
"周三": 1,
|
||||
"周四": 0,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 0,
|
||||
},
|
||||
{
|
||||
"stage": ["CA-5"],
|
||||
"周一": 0,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 0,
|
||||
"周五": 1,
|
||||
"周六": 0,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-A-2"],
|
||||
"周一": 1,
|
||||
"周二": 0,
|
||||
"周三": 0,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 0,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-A-2"],
|
||||
"周一": 1,
|
||||
"周二": 0,
|
||||
"周三": 0,
|
||||
"周四": 1,
|
||||
"周五": 1,
|
||||
"周六": 0,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-B-2"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 0,
|
||||
"周四": 0,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 0,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-B-1"],
|
||||
"周一": 1,
|
||||
"周二": 1,
|
||||
"周三": 0,
|
||||
"周四": 0,
|
||||
"周五": 1,
|
||||
"周六": 1,
|
||||
"周日": 0,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-C-2"],
|
||||
"周一": 0,
|
||||
"周二": 0,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-C-1"],
|
||||
"周一": 0,
|
||||
"周二": 0,
|
||||
"周三": 1,
|
||||
"周四": 1,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-D-2"],
|
||||
"周一": 0,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 0,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
{
|
||||
"stage": ["PR-D-1"],
|
||||
"周一": 0,
|
||||
"周二": 1,
|
||||
"周三": 1,
|
||||
"周四": 0,
|
||||
"周五": 0,
|
||||
"周六": 1,
|
||||
"周日": 1,
|
||||
},
|
||||
]
|
||||
"周计划(新)"
|
||||
maa_depot_enable: bool = False
|
||||
"仓库物品混合读取"
|
||||
visit_friend: bool = True
|
||||
"访问好友"
|
||||
report_enable: bool = True
|
||||
"读取基报"
|
||||
|
||||
|
||||
class RIICPart(ConfModel):
|
||||
class RunOrderGrandetModeConf(ConfModel):
|
||||
enable: bool = True
|
||||
"葛朗台跑单开关"
|
||||
buffer_time: int = 15
|
||||
"缓冲时间"
|
||||
back_to_index: bool = False
|
||||
"跑单前返回基建首页"
|
||||
|
||||
drone_count_limit: int = 100
|
||||
"无人机使用阈值"
|
||||
drone_room: str = ""
|
||||
"无人机使用房间"
|
||||
drone_interval: float = 3
|
||||
"无人机加速间隔"
|
||||
free_blacklist: str = ""
|
||||
"宿舍黑名单"
|
||||
reload_room: str = ""
|
||||
"搓玉补货房间"
|
||||
run_order_delay: float = 3
|
||||
"跑单前置延时"
|
||||
resting_threshold: float = 0.65
|
||||
"心情阈值"
|
||||
run_order_grandet_mode: RunOrderGrandetModeConf
|
||||
"葛朗台跑单"
|
||||
free_room: bool = False
|
||||
"宿舍不养闲人模式"
|
||||
|
||||
|
||||
class SimulatorPart(ConfModel):
|
||||
class SimulatorConf(ConfModel):
|
||||
name: str = ""
|
||||
"名称"
|
||||
index: str | int = "-1"
|
||||
"多开编号"
|
||||
simulator_folder: str = ""
|
||||
"文件夹"
|
||||
wait_time: int = 30
|
||||
"启动时间"
|
||||
hotkey: str = ""
|
||||
"老板键"
|
||||
|
||||
class CustomScreenshotConf(ConfModel):
|
||||
command: str = "adb -s 127.0.0.1:5555 shell screencap -p 2>/dev/null"
|
||||
"截图命令"
|
||||
enable: bool = False
|
||||
"是否启用自定义截图"
|
||||
|
||||
class TapToLaunchGameConf(ConfModel):
|
||||
enable: bool = False
|
||||
"点击屏幕启动游戏"
|
||||
x: int = 0
|
||||
"横坐标"
|
||||
y: int = 0
|
||||
"纵坐标"
|
||||
|
||||
class DroidCastConf(ConfModel):
|
||||
enable: bool = True
|
||||
"使用DroidCast截图"
|
||||
rotate: bool = False
|
||||
"将截图旋转180度"
|
||||
|
||||
adb: str = "127.0.0.1:16384"
|
||||
"ADB连接地址"
|
||||
simulator: SimulatorConf
|
||||
"模拟器"
|
||||
maa_adb_path: str = "D:\\Program Files\\Nox\\bin\\adb.exe"
|
||||
"ADB路径"
|
||||
close_simulator_when_idle: bool = False
|
||||
"任务结束后关闭游戏"
|
||||
package_type: int = 1
|
||||
"游戏服务器"
|
||||
custom_screenshot: CustomScreenshotConf
|
||||
"自定义截图"
|
||||
tap_to_launch_game: TapToLaunchGameConf
|
||||
"点击屏幕启动游戏"
|
||||
exit_game_when_idle: bool = True
|
||||
"任务结束后退出游戏"
|
||||
close_simulator_when_idle: bool = False
|
||||
"任务结束后关闭模拟器"
|
||||
fix_mumu12_adb_disconnect: bool = False
|
||||
"关闭MuMu模拟器12时结束adb进程"
|
||||
touch_method: str = "scrcpy"
|
||||
"触控模式"
|
||||
droidcast: DroidCastConf
|
||||
"DroidCast截图设置"
|
||||
|
||||
|
||||
class SKLandPart(ConfModel):
|
||||
class SKLandAccount(BaseModel):
|
||||
account: str = ""
|
||||
"账号"
|
||||
password: str = ""
|
||||
"密码"
|
||||
cultivate_select: bool = True
|
||||
"服务器"
|
||||
isCheck: bool = True
|
||||
"签到"
|
||||
sign_in_bilibili: bool = False
|
||||
"官服签到"
|
||||
sign_in_official: bool = False
|
||||
"B服签到"
|
||||
|
||||
skland_enable: bool = False
|
||||
"森空岛签到"
|
||||
skland_info: list[SKLandAccount] = []
|
||||
"森空岛账号"
|
||||
|
||||
|
||||
class Conf(
|
||||
CluePart,
|
||||
EmailPart,
|
||||
ExtraPart,
|
||||
LongTaskPart,
|
||||
MaaPart,
|
||||
RecruitPart,
|
||||
RegularTaskPart,
|
||||
RIICPart,
|
||||
SimulatorPart,
|
||||
SKLandPart,
|
||||
):
|
||||
@property
|
||||
def APPNAME(self):
|
||||
return (
|
||||
"com.hypergryph.arknights"
|
||||
if self.package_type == 1
|
||||
else "com.hypergryph.arknights.bilibili"
|
||||
)
|
||||
|
||||
@property
|
||||
def RG(self):
|
||||
return self.maa_rg_enable == 1 and self.maa_long_task_type == "rogue"
|
||||
|
||||
@property
|
||||
def SSS(self):
|
||||
return self.maa_rg_enable == 1 and self.maa_long_task_type == "sss"
|
||||
|
||||
@property
|
||||
def RA(self):
|
||||
return self.maa_rg_enable == 1 and self.maa_long_task_type == "ra"
|
||||
|
||||
@property
|
||||
def SF(self):
|
||||
return self.maa_rg_enable == 1 and self.maa_long_task_type == "sf"
|
||||
|
||||
@property
|
||||
def run_order_buffer_time(self):
|
||||
"""
|
||||
> 0 葛朗台跑单的缓冲时间
|
||||
<= 0 无人机跑单
|
||||
"""
|
||||
if self.run_order_grandet_mode.enable:
|
||||
return self.run_order_grandet_mode.buffer_time
|
||||
return -1
|
112
mower/utils/config/plan.py
Normal file
112
mower/utils/config/plan.py
Normal file
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PlanConf(BaseModel):
|
||||
ling_xi: int = 1
|
||||
"令夕模式,1感知 2烟火 3均衡"
|
||||
max_resting_count: int = 0
|
||||
"最大组人数"
|
||||
exhaust_require: str = ""
|
||||
"耗尽"
|
||||
rest_in_full: str = ""
|
||||
"回满"
|
||||
resting_priority: str = ""
|
||||
"低优先级"
|
||||
workaholic: str = ""
|
||||
"0心情工作(主力宿舍黑名单)"
|
||||
refresh_trading: str = ""
|
||||
"跑单时间刷新干员"
|
||||
|
||||
|
||||
class BackupPlanConf(PlanConf):
|
||||
free_blacklist: str = ""
|
||||
"(非主力)宿舍黑名单"
|
||||
|
||||
|
||||
class Plans(BaseModel):
|
||||
agent: str
|
||||
group: str = ""
|
||||
replacement: list[str] = []
|
||||
|
||||
|
||||
class Facility(BaseModel):
|
||||
name: str = ""
|
||||
plans: list[Plans] = []
|
||||
product: Optional[str] = None
|
||||
|
||||
|
||||
class Plan1(BaseModel):
|
||||
central: Optional[Facility] = None
|
||||
"控制中枢"
|
||||
meeting: Optional[Facility] = None
|
||||
"会客室"
|
||||
factory: Optional[Facility] = None
|
||||
"加工站"
|
||||
contact: Optional[Facility] = None
|
||||
"办公室"
|
||||
train: Optional[Facility] = None
|
||||
"训练室"
|
||||
dormitory_1: Optional[Facility] = None
|
||||
dormitory_2: Optional[Facility] = None
|
||||
dormitory_3: Optional[Facility] = None
|
||||
dormitory_4: Optional[Facility] = None
|
||||
room_1_1: Optional[Facility] = None
|
||||
room_1_2: Optional[Facility] = None
|
||||
room_1_3: Optional[Facility] = None
|
||||
room_2_1: Optional[Facility] = None
|
||||
room_2_2: Optional[Facility] = None
|
||||
room_2_3: Optional[Facility] = None
|
||||
room_3_1: Optional[Facility] = None
|
||||
room_3_2: Optional[Facility] = None
|
||||
room_3_3: Optional[Facility] = None
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
central: Optional[list[str]] = None
|
||||
"控制中枢"
|
||||
meeting: Optional[list[str]] = None
|
||||
"会客室"
|
||||
factory: Optional[list[str]] = None
|
||||
"加工站"
|
||||
contact: Optional[list[str]] = None
|
||||
"办公室"
|
||||
train: Optional[list[str]] = None
|
||||
"训练室"
|
||||
dormitory_1: Optional[list[str]] = None
|
||||
dormitory_2: Optional[list[str]] = None
|
||||
dormitory_3: Optional[list[str]] = None
|
||||
dormitory_4: Optional[list[str]] = None
|
||||
room_1_1: Optional[list[str]] = None
|
||||
room_1_2: Optional[list[str]] = None
|
||||
room_1_3: Optional[list[str]] = None
|
||||
room_2_1: Optional[list[str]] = None
|
||||
room_2_2: Optional[list[str]] = None
|
||||
room_2_3: Optional[list[str]] = None
|
||||
room_3_1: Optional[list[str]] = None
|
||||
room_3_2: Optional[list[str]] = None
|
||||
room_3_3: Optional[list[str]] = None
|
||||
|
||||
|
||||
class Trigger(BaseModel):
|
||||
left: str | Trigger = ""
|
||||
operator: str = ""
|
||||
right: str | Trigger = ""
|
||||
|
||||
|
||||
class BackupPlan(BaseModel):
|
||||
conf: BackupPlanConf = {}
|
||||
plan: Plan1 = {}
|
||||
task: Task = {}
|
||||
trigger: Trigger = {}
|
||||
trigger_timing: str = "AFTER_PLANNING"
|
||||
|
||||
|
||||
class PlanModel(BaseModel):
|
||||
default: str = "plan1"
|
||||
plan1: Plan1 = Plan1()
|
||||
conf: PlanConf = PlanConf()
|
||||
backup_plans: list[BackupPlan] = []
|
23
mower/utils/csleep.py
Normal file
23
mower/utils/csleep.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from mower.utils import config
|
||||
|
||||
|
||||
class MowerExit(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def csleep(interval: float = 1):
|
||||
"""check and sleep"""
|
||||
stop_time = datetime.now() + timedelta(seconds=interval)
|
||||
while True:
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
remaining = stop_time - datetime.now()
|
||||
if remaining > timedelta(seconds=1):
|
||||
time.sleep(1)
|
||||
elif remaining > timedelta():
|
||||
time.sleep(remaining.total_seconds())
|
||||
else:
|
||||
return
|
35
mower/utils/datetime.py
Normal file
35
mower/utils/datetime.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
def the_same_day(a: datetime = None, b: datetime = None) -> bool:
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return a.year == b.year and a.month == b.month and a.day == b.day
|
||||
|
||||
|
||||
def the_same_time(a: datetime | None = None, b: datetime | None = None) -> bool:
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return abs(a - b).total_seconds() < 1.5
|
||||
|
||||
|
||||
def get_server_weekday():
|
||||
return datetime.now(pytz.timezone("Asia/Dubai")).weekday()
|
||||
|
||||
|
||||
# newbing说用这个来定义休息时间省事
|
||||
def format_time(seconds):
|
||||
if seconds < 0: # 权宜之计 配合刷生息演算
|
||||
return f"{0} 分钟" # 权宜之计 配合刷生息演算
|
||||
# 计算小时和分钟
|
||||
rest_hours = int(seconds / 3600)
|
||||
rest_minutes = int((seconds % 3600) / 60)
|
||||
# 根据小时是否为零来决定是否显示
|
||||
if rest_hours == 0:
|
||||
return f"{rest_minutes} 分钟"
|
||||
elif rest_minutes == 0:
|
||||
return f"{rest_hours} 小时"
|
||||
else:
|
||||
return f"{rest_hours} 小时 {rest_minutes} 分钟"
|
235
mower/utils/depot.py
Normal file
235
mower/utils/depot.py
Normal file
|
@ -0,0 +1,235 @@
|
|||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# from .log import logger
|
||||
from mower.data import key_mapping
|
||||
from mower.utils.log import logger
|
||||
|
||||
# from typing import Dict, List, Union
|
||||
from mower.utils.path import get_path
|
||||
|
||||
|
||||
def 读取仓库():
|
||||
path = get_path("@app/tmp/cultivate.json")
|
||||
if not os.path.exists(path):
|
||||
创建json()
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
depotinfo = json.load(f)
|
||||
|
||||
物品数量 = depotinfo["data"]["items"]
|
||||
logger.info(depotinfo["timestamp"])
|
||||
time = int(depotinfo["timestamp"])
|
||||
新物品1 = {
|
||||
key_mapping[item["id"]][2]: int(item["count"])
|
||||
for item in 物品数量
|
||||
if int(item["count"]) != 0
|
||||
}
|
||||
|
||||
csv_path = get_path("@app/tmp/depotresult.csv")
|
||||
if not os.path.exists(csv_path):
|
||||
创建csv()
|
||||
|
||||
# 读取CSV文件
|
||||
depotinfo = pd.read_csv(csv_path)
|
||||
|
||||
# 取出最后一行数据中的物品信息并进行合并
|
||||
最后一行物品 = json.loads(depotinfo.iloc[-1, 1])
|
||||
新物品 = {**最后一行物品, **新物品1} # 合并字典
|
||||
新物品json = {}
|
||||
for item in 新物品:
|
||||
新物品json[key_mapping[item][0]] = 新物品[item]
|
||||
# time = depotinfo.iloc[-1, 0]
|
||||
|
||||
sort = {
|
||||
"A常用": [
|
||||
"至纯源石",
|
||||
"合成玉",
|
||||
"寻访凭证",
|
||||
"十连寻访凭证",
|
||||
"龙门币",
|
||||
"高级凭证",
|
||||
"资质凭证",
|
||||
"招聘许可",
|
||||
],
|
||||
"B经验卡": ["基础作战记录", "初级作战记录", "中级作战记录", "高级作战记录"],
|
||||
"C稀有度5": ["烧结核凝晶", "晶体电子单元", "D32钢", "双极纳米片", "聚合剂"],
|
||||
"D稀有度4": [
|
||||
"提纯源岩",
|
||||
"改量装置",
|
||||
"聚酸酯块",
|
||||
"糖聚块",
|
||||
"异铁块",
|
||||
"酮阵列",
|
||||
"转质盐聚块",
|
||||
"切削原液",
|
||||
"精炼溶剂",
|
||||
"晶体电路",
|
||||
"炽合金块",
|
||||
"聚合凝胶",
|
||||
"白马醇",
|
||||
"三水锰矿",
|
||||
"五水研磨石",
|
||||
"RMA70-24",
|
||||
"环烃预制体",
|
||||
"固化纤维板",
|
||||
],
|
||||
"E稀有度3": [
|
||||
"固源岩组",
|
||||
"全新装置",
|
||||
"聚酸酯组",
|
||||
"糖组",
|
||||
"异铁组",
|
||||
"酮凝集组",
|
||||
"转质盐组",
|
||||
"化合切削液",
|
||||
"半自然溶剂",
|
||||
"晶体元件",
|
||||
"炽合金",
|
||||
"凝胶",
|
||||
"扭转醇",
|
||||
"轻锰矿",
|
||||
"研磨石",
|
||||
"RMA70-12",
|
||||
"环烃聚质",
|
||||
"褐素纤维",
|
||||
],
|
||||
"F稀有度2": ["固源岩", "装置", "聚酸酯", "糖", "异铁", "酮凝集"],
|
||||
"G稀有度1": ["源岩", "破损装置", "酯原料", "代糖", "异铁碎片", "双酮"],
|
||||
"H模组": ["模组数据块", "数据增补仪", "数据增补条"],
|
||||
"I技能书": ["技巧概要·卷3", "技巧概要·卷2", "技巧概要·卷1"],
|
||||
"J芯片相关": [
|
||||
"重装双芯片",
|
||||
"重装芯片组",
|
||||
"重装芯片",
|
||||
"狙击双芯片",
|
||||
"狙击芯片组",
|
||||
"狙击芯片",
|
||||
"医疗双芯片",
|
||||
"医疗芯片组",
|
||||
"医疗芯片",
|
||||
"术师双芯片",
|
||||
"术师芯片组",
|
||||
"术师芯片",
|
||||
"先锋双芯片",
|
||||
"先锋芯片组",
|
||||
"先锋芯片",
|
||||
"近卫双芯片",
|
||||
"近卫芯片组",
|
||||
"近卫芯片",
|
||||
"辅助双芯片",
|
||||
"辅助芯片组",
|
||||
"辅助芯片",
|
||||
"特种双芯片",
|
||||
"特种芯片组",
|
||||
"特种芯片",
|
||||
"采购凭证",
|
||||
"芯片助剂",
|
||||
],
|
||||
"K未分类": [],
|
||||
}
|
||||
classified_data = {}
|
||||
classified_data["K未分类"] = {}
|
||||
for category, items in sort.items():
|
||||
classified_data[category] = {
|
||||
item: {"number": 0, "sort": key_mapping[item][4], "icon": item}
|
||||
for item in items
|
||||
}
|
||||
|
||||
for key, value in 新物品.items():
|
||||
found_category = False
|
||||
for category, items in sort.items():
|
||||
if key in items:
|
||||
classified_data[category][key] = {
|
||||
"number": value,
|
||||
"sort": key_mapping[key][4],
|
||||
"icon": key,
|
||||
}
|
||||
found_category = True
|
||||
break
|
||||
if not found_category:
|
||||
# 如果未找到匹配的分类,则放入 "K未分类" 中
|
||||
classified_data["K未分类"][key] = {
|
||||
"number": value,
|
||||
"sort": key_mapping[key][4],
|
||||
"icon": key,
|
||||
}
|
||||
|
||||
classified_data["B经验卡"]["全部经验(计算)"] = {
|
||||
"number": (
|
||||
classified_data["B经验卡"]["基础作战记录"]["number"] * 200
|
||||
+ classified_data["B经验卡"]["初级作战记录"]["number"] * 400
|
||||
+ classified_data["B经验卡"]["中级作战记录"]["number"] * 1000
|
||||
+ classified_data["B经验卡"]["高级作战记录"]["number"] * 2000
|
||||
),
|
||||
"sort": 9999999,
|
||||
"icon": "EXP",
|
||||
}
|
||||
合成玉数量 = classified_data["A常用"].get("合成玉", {"number": 0})["number"]
|
||||
寻访凭证数量 = (
|
||||
classified_data["A常用"].get("寻访凭证", {"number": 0})["number"]
|
||||
+ classified_data["A常用"].get("十连寻访凭证", {"number": 0})["number"] * 10
|
||||
)
|
||||
源石数量 = classified_data["A常用"].get("至纯源石", {"number": 0})["number"]
|
||||
源石碎片 = classified_data["K未分类"].get("源石碎片", {"number": 0})["number"]
|
||||
|
||||
土 = classified_data["F稀有度2"].get("固源岩", {"number": 0})["number"]
|
||||
classified_data["A常用"]["玉+卷"] = {
|
||||
"number": round(合成玉数量 / 600 + 寻访凭证数量, 1),
|
||||
"sort": 9999999,
|
||||
"icon": "寻访凭证",
|
||||
}
|
||||
classified_data["A常用"]["玉+卷+石"] = {
|
||||
"number": round((合成玉数量 + 源石数量 * 180) / 600 + 寻访凭证数量, 1),
|
||||
"sort": 9999999,
|
||||
"icon": "寻访凭证",
|
||||
}
|
||||
classified_data["A常用"]["额外+碎片"] = {
|
||||
"number": round(
|
||||
(合成玉数量 + 源石数量 * 180 + int(源石碎片 / 2) * 20) / 600 + 寻访凭证数量,
|
||||
1,
|
||||
),
|
||||
"sort": 9999999,
|
||||
"icon": "寻访凭证",
|
||||
}
|
||||
待定碎片 = int(土 / 2)
|
||||
classified_data["A常用"]["额外+碎片+土"] = {
|
||||
"number": round(
|
||||
(合成玉数量 + 源石数量 * 180 + int((源石碎片 + 待定碎片) / 2) * 20) / 600
|
||||
+ 寻访凭证数量,
|
||||
1,
|
||||
),
|
||||
"sort": 9999999,
|
||||
"icon": "寻访凭证",
|
||||
}
|
||||
return [
|
||||
classified_data,
|
||||
json.dumps(新物品json),
|
||||
str(datetime.fromtimestamp(int(time))),
|
||||
]
|
||||
|
||||
|
||||
def 创建csv():
|
||||
path = get_path("@app/tmp/depotresult.csv")
|
||||
now_time = int(datetime.now().timestamp()) - 24 * 3600
|
||||
result = [
|
||||
now_time,
|
||||
json.dumps({"还未开始过扫描": 0}, ensure_ascii=False),
|
||||
json.dumps({"空": ""}, ensure_ascii=False),
|
||||
]
|
||||
depotinfo = pd.DataFrame([result], columns=["Timestamp", "Data", "json"])
|
||||
depotinfo.to_csv(path, mode="a", index=False, header=True, encoding="utf-8")
|
||||
|
||||
|
||||
def 创建json():
|
||||
path = get_path("@app/tmp/cultivate.json")
|
||||
a = {
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"timestamp": "0",
|
||||
"data": {"items": [{"id": "0", "count": "0"}]},
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(a, f)
|
13
mower/utils/deprecated.py
Normal file
13
mower/utils/deprecated.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import functools
|
||||
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.traceback import caller_info
|
||||
|
||||
|
||||
def deprecated(func):
|
||||
@functools.wraps(func)
|
||||
def new_func(*args, **kwargs):
|
||||
logger.warning(f"已弃用的函数{func.__name__}被{caller_info()}调用")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return new_func
|
41
mower/utils/detector.py
Normal file
41
mower/utils/detector.py
Normal file
|
@ -0,0 +1,41 @@
|
|||
import numpy as np
|
||||
|
||||
from . import typealias as tp
|
||||
from .log import logger
|
||||
|
||||
|
||||
def infra_notification(img: tp.Image) -> tp.Coordinate:
|
||||
"""
|
||||
检测基建内是否存在蓝色通知
|
||||
前置条件:已经处于基建内
|
||||
"""
|
||||
height, width, _ = img.shape
|
||||
|
||||
# set a new scan line: right
|
||||
right = width
|
||||
while np.max(img[:, right - 1]) < 100:
|
||||
right -= 1
|
||||
right -= 1
|
||||
|
||||
# set a new scan line: up
|
||||
up = 0
|
||||
for i in range(height):
|
||||
if img[i, right, 0] < 100 < img[i, right, 1] < img[i, right, 2]:
|
||||
up = i
|
||||
break
|
||||
if up == 0:
|
||||
return None
|
||||
|
||||
# set a new scan line: down
|
||||
down = 0
|
||||
for i in range(up, height):
|
||||
if not (img[i, right, 0] < 100 < img[i, right, 1] < img[i, right, 2]):
|
||||
down = i
|
||||
break
|
||||
if down == 0:
|
||||
return None
|
||||
|
||||
# detect successful
|
||||
point = (right - 10, (up + down) // 2)
|
||||
logger.debug(f"detector.infra_notification: {point}")
|
||||
return point
|
0
mower/utils/device/__init__.py
Normal file
0
mower/utils/device/__init__.py
Normal file
0
mower/utils/device/adb_client/__init__.py
Normal file
0
mower/utils/device/adb_client/__init__.py
Normal file
101
mower/utils/device/adb_client/const.py
Normal file
101
mower/utils/device/adb_client/const.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
class KeyCode:
|
||||
"""https://developer.android.com/reference/android/view/KeyEvent.html"""
|
||||
|
||||
KEYCODE_CALL = 5 # 拨号键
|
||||
KEYCODE_ENDCALL = 6 # 挂机键
|
||||
KEYCODE_HOME = 3 # Home 键
|
||||
KEYCODE_MENU = 82 # 菜单键
|
||||
KEYCODE_BACK = 4 # 返回键
|
||||
KEYCODE_SEARCH = 84 # 搜索键
|
||||
KEYCODE_CAMERA = 27 # 拍照键
|
||||
KEYCODE_FOCUS = 80 # 对焦键
|
||||
KEYCODE_POWER = 26 # 电源键
|
||||
KEYCODE_NOTIFICATION = 83 # 通知键
|
||||
KEYCODE_MUTE = 91 # 话筒静音键
|
||||
KEYCODE_VOLUME_MUTE = 164 # 扬声器静音键
|
||||
KEYCODE_VOLUME_UP = 24 # 音量 + 键
|
||||
KEYCODE_VOLUME_DOWN = 25 # 音量 - 键
|
||||
KEYCODE_ENTER = 66 # 回车键
|
||||
KEYCODE_ESCAPE = 111 # ESC 键
|
||||
KEYCODE_DPAD_CENTER = 23 # 导航键 >> 确定键
|
||||
KEYCODE_DPAD_UP = 19 # 导航键 >> 向上
|
||||
KEYCODE_DPAD_DOWN = 20 # 导航键 >> 向下
|
||||
KEYCODE_DPAD_LEFT = 21 # 导航键 >> 向左
|
||||
KEYCODE_DPAD_RIGHT = 22 # 导航键 >> 向右
|
||||
KEYCODE_MOVE_HOME = 122 # 光标移动到开始键
|
||||
KEYCODE_MOVE_END = 123 # 光标移动到末尾键
|
||||
KEYCODE_PAGE_UP = 92 # 向上翻页键
|
||||
KEYCODE_PAGE_DOWN = 93 # 向下翻页键
|
||||
KEYCODE_DEL = 67 # 退格键
|
||||
KEYCODE_FORWARD_DEL = 112 # 删除键
|
||||
KEYCODE_INSERT = 124 # 插入键
|
||||
KEYCODE_TAB = 61 # Tab 键
|
||||
KEYCODE_NUM_LOCK = 143 # 小键盘锁
|
||||
KEYCODE_CAPS_LOCK = 115 # 大写锁定键
|
||||
KEYCODE_BREAK = 121 # Break / Pause 键
|
||||
KEYCODE_SCROLL_LOCK = 116 # 滚动锁定键
|
||||
KEYCODE_ZOOM_IN = 168 # 放大键
|
||||
KEYCODE_ZOOM_OUT = 169 # 缩小键
|
||||
KEYCODE_0 = 7 # 0
|
||||
KEYCODE_1 = 8 # 1
|
||||
KEYCODE_2 = 9 # 2
|
||||
KEYCODE_3 = 10 # 3
|
||||
KEYCODE_4 = 11 # 4
|
||||
KEYCODE_5 = 12 # 5
|
||||
KEYCODE_6 = 13 # 6
|
||||
KEYCODE_7 = 14 # 7
|
||||
KEYCODE_8 = 15 # 8
|
||||
KEYCODE_9 = 16 # 9
|
||||
KEYCODE_A = 29 # A
|
||||
KEYCODE_B = 30 # B
|
||||
KEYCODE_C = 31 # C
|
||||
KEYCODE_D = 32 # D
|
||||
KEYCODE_E = 33 # E
|
||||
KEYCODE_F = 34 # F
|
||||
KEYCODE_G = 35 # G
|
||||
KEYCODE_H = 36 # H
|
||||
KEYCODE_I = 37 # I
|
||||
KEYCODE_J = 38 # J
|
||||
KEYCODE_K = 39 # K
|
||||
KEYCODE_L = 40 # L
|
||||
KEYCODE_M = 41 # M
|
||||
KEYCODE_N = 42 # N
|
||||
KEYCODE_O = 43 # O
|
||||
KEYCODE_P = 44 # P
|
||||
KEYCODE_Q = 45 # Q
|
||||
KEYCODE_R = 46 # R
|
||||
KEYCODE_S = 47 # S
|
||||
KEYCODE_T = 48 # T
|
||||
KEYCODE_U = 49 # U
|
||||
KEYCODE_V = 50 # V
|
||||
KEYCODE_W = 51 # W
|
||||
KEYCODE_X = 52 # X
|
||||
KEYCODE_Y = 53 # Y
|
||||
KEYCODE_Z = 54 # Z
|
||||
KEYCODE_PLUS = 81 # +
|
||||
KEYCODE_MINUS = 69 # -
|
||||
KEYCODE_STAR = 17 # *
|
||||
KEYCODE_SLASH = 76 # /
|
||||
KEYCODE_EQUALS = 70 # =
|
||||
KEYCODE_AT = 77 # @
|
||||
KEYCODE_POUND = 18 # #
|
||||
KEYCODE_APOSTROPHE = 75 # '
|
||||
KEYCODE_BACKSLASH = 73 # \
|
||||
KEYCODE_COMMA = 55 # ,
|
||||
KEYCODE_PERIOD = 56 # .
|
||||
KEYCODE_LEFT_BRACKET = 71 # [
|
||||
KEYCODE_RIGHT_BRACKET = 72 # ]
|
||||
KEYCODE_SEMICOLON = 74 # ;
|
||||
KEYCODE_GRAVE = 68 # `
|
||||
KEYCODE_SPACE = 62 # 空格键
|
||||
KEYCODE_MEDIA_PLAY = 126 # 多媒体键 >> 播放
|
||||
KEYCODE_MEDIA_STOP = 86 # 多媒体键 >> 停止
|
||||
KEYCODE_MEDIA_PAUSE = 127 # 多媒体键 >> 暂停
|
||||
KEYCODE_MEDIA_PLAY_PAUSE = 85 # 多媒体键 >> 播放 / 暂停
|
||||
KEYCODE_MEDIA_FAST_FORWARD = 90 # 多媒体键 >> 快进
|
||||
KEYCODE_MEDIA_REWIND = 89 # 多媒体键 >> 快退
|
||||
KEYCODE_MEDIA_NEXT = 87 # 多媒体键 >> 下一首
|
||||
KEYCODE_MEDIA_PREVIOUS = 88 # 多媒体键 >> 上一首
|
||||
KEYCODE_MEDIA_CLOSE = 128 # 多媒体键 >> 关闭
|
||||
KEYCODE_MEDIA_EJECT = 129 # 多媒体键 >> 弹出
|
||||
KEYCODE_MEDIA_RECORD = 130 # 多媒体键 >> 录音
|
195
mower/utils/device/adb_client/core.py
Normal file
195
mower/utils/device/adb_client/core.py
Normal file
|
@ -0,0 +1,195 @@
|
|||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
|
||||
from mower import __system__
|
||||
from mower.utils import config
|
||||
from mower.utils.csleep import csleep
|
||||
from mower.utils.device.adb_client.session import Session
|
||||
from mower.utils.device.adb_client.socket import Socket
|
||||
from mower.utils.device.adb_client.utils import run_cmd
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class Client:
|
||||
"""ADB Client"""
|
||||
|
||||
def __init__(
|
||||
self, device_id: str = None, connect: str = None, adb_bin: str = None
|
||||
) -> None:
|
||||
self.device_id = device_id
|
||||
self.connect = connect
|
||||
self.adb_bin = adb_bin
|
||||
self.error_limit = 3
|
||||
self.__init_adb()
|
||||
self.__init_device()
|
||||
|
||||
def __init_adb(self) -> None:
|
||||
if self.adb_bin is not None:
|
||||
return
|
||||
adb_bin = config.conf.maa_adb_path
|
||||
logger.debug(adb_bin)
|
||||
if self.__check_adb(adb_bin):
|
||||
self.adb_bin = adb_bin
|
||||
return
|
||||
raise RuntimeError("Can't start adb server")
|
||||
|
||||
def __init_device(self) -> None:
|
||||
# wait for the newly started ADB server to probe emulators
|
||||
csleep(1)
|
||||
if self.device_id is None or self.device_id != config.conf.adb:
|
||||
self.device_id = self.__choose_devices()
|
||||
if self.device_id is None:
|
||||
if self.connect is None:
|
||||
Session().connect()
|
||||
else:
|
||||
Session().connect(self.connect)
|
||||
self.device_id = self.__choose_devices()
|
||||
elif self.connect is None:
|
||||
Session().connect(self.device_id)
|
||||
|
||||
# if self.device_id is None or self.device_id not in config.ADB_DEVICE:
|
||||
# if self.connect is None or self.device_id not in config.ADB_CONNECT:
|
||||
# for connect in config.ADB_CONNECT:
|
||||
# Session().connect(connect)
|
||||
# else:
|
||||
# Session().connect(self.connect)
|
||||
# self.device_id = self.__choose_devices()
|
||||
logger.info(self.__available_devices())
|
||||
if self.device_id not in self.__available_devices():
|
||||
logger.error(
|
||||
"未检测到相应设备。请运行 `adb devices` 确认列表中列出了目标模拟器或设备。"
|
||||
)
|
||||
raise RuntimeError("Device connection failure")
|
||||
|
||||
def __choose_devices(self) -> Optional[str]:
|
||||
"""choose available devices"""
|
||||
devices = self.__available_devices()
|
||||
if config.conf.adb in devices:
|
||||
return config.conf.adb
|
||||
if len(devices) > 0 and config.conf.adb == "":
|
||||
logger.debug(devices[0])
|
||||
return devices[0]
|
||||
|
||||
def __available_devices(self) -> list[str]:
|
||||
"""return available devices"""
|
||||
return [x[0] for x in Session().devices_list() if x[1] != "offline"]
|
||||
|
||||
def __exec(self, cmd: str, adb_bin: str = None) -> None:
|
||||
"""exec command with adb_bin"""
|
||||
logger.debug(cmd)
|
||||
if adb_bin is None:
|
||||
adb_bin = self.adb_bin
|
||||
subprocess.run(
|
||||
[adb_bin, cmd],
|
||||
check=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if __system__ == "windows" else 0,
|
||||
)
|
||||
|
||||
def __run(self, cmd: str, restart: bool = True) -> Optional[bytes]:
|
||||
"""run command with Session"""
|
||||
error_limit = 3
|
||||
while True:
|
||||
try:
|
||||
return Session().run(cmd)
|
||||
except (socket.timeout, ConnectionRefusedError, RuntimeError):
|
||||
if restart and error_limit > 0:
|
||||
error_limit -= 1
|
||||
self.__exec("kill-server")
|
||||
self.__exec("start-server")
|
||||
time.sleep(10)
|
||||
continue
|
||||
return
|
||||
|
||||
def check_server_alive(self, restart: bool = True) -> bool:
|
||||
"""check adb server if it works"""
|
||||
return self.__run("host:version", restart) is not None
|
||||
|
||||
def __check_adb(self, adb_bin: str) -> bool:
|
||||
"""check adb_bin if it works"""
|
||||
try:
|
||||
self.__exec("start-server", adb_bin)
|
||||
if self.check_server_alive(False):
|
||||
return True
|
||||
self.__exec("kill-server", adb_bin)
|
||||
self.__exec("start-server", adb_bin)
|
||||
time.sleep(10)
|
||||
if self.check_server_alive(False):
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.CalledProcessError):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
|
||||
def session(self) -> Session:
|
||||
"""get a session between adb client and adb server"""
|
||||
if not self.check_server_alive():
|
||||
raise RuntimeError("ADB server is not working")
|
||||
return Session().device(self.device_id)
|
||||
|
||||
def run(self, cmd: str) -> Optional[bytes]:
|
||||
"""run adb exec command"""
|
||||
logger.debug(cmd)
|
||||
error_limit = 3
|
||||
while True:
|
||||
try:
|
||||
resp = self.session().exec(cmd)
|
||||
break
|
||||
except (socket.timeout, ConnectionRefusedError, RuntimeError) as e:
|
||||
if error_limit > 0:
|
||||
error_limit -= 1
|
||||
self.__exec("kill-server")
|
||||
self.__exec("start-server")
|
||||
time.sleep(10)
|
||||
self.__init_device()
|
||||
continue
|
||||
raise e
|
||||
if len(resp) <= 256:
|
||||
logger.debug(repr(resp))
|
||||
return resp
|
||||
|
||||
def cmd(self, cmd: str | list[str], decode: bool = False) -> Union[bytes, str]:
|
||||
"""run adb command with adb_bin"""
|
||||
if isinstance(cmd, str):
|
||||
cmd = cmd.split(" ")
|
||||
cmd = [self.adb_bin, "-s", self.device_id] + cmd
|
||||
return run_cmd(cmd, decode)
|
||||
|
||||
def cmd_shell(self, cmd: str, decode: bool = False) -> Union[bytes, str]:
|
||||
"""run adb shell command with adb_bin"""
|
||||
cmd = [self.adb_bin, "-s", self.device_id, "shell"] + cmd.split(" ")
|
||||
return run_cmd(cmd, decode)
|
||||
|
||||
def cmd_push(self, filepath: str, target: str) -> None:
|
||||
"""push file into device with adb_bin"""
|
||||
cmd = [self.adb_bin, "-s", self.device_id, "push", filepath, target]
|
||||
run_cmd(cmd)
|
||||
|
||||
def process(
|
||||
self, path: str, args: list[str] = [], stderr: int = subprocess.DEVNULL
|
||||
) -> subprocess.Popen:
|
||||
logger.debug(f"{path=} {args=}")
|
||||
cmd = [self.adb_bin, "-s", self.device_id, "shell", path] + args
|
||||
return subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=stderr,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if __system__ == "windows" else 0,
|
||||
)
|
||||
|
||||
def push(self, target_path: str, target: bytes) -> None:
|
||||
"""push file into device"""
|
||||
self.session().push(target_path, target)
|
||||
|
||||
def stream(self, cmd: str) -> Socket:
|
||||
"""run adb command, return socket"""
|
||||
return self.session().request(cmd, True).sock
|
||||
|
||||
def stream_shell(self, cmd: str) -> Socket:
|
||||
"""run adb shell command, return socket"""
|
||||
return self.stream("shell:" + cmd)
|
||||
|
||||
def android_version(self) -> str:
|
||||
"""get android_version"""
|
||||
return self.cmd_shell("getprop ro.build.version.release", True)
|
124
mower/utils/device/adb_client/session.py
Normal file
124
mower/utils/device/adb_client/session.py
Normal file
|
@ -0,0 +1,124 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from mower.utils import config
|
||||
from mower.utils.device.adb_client.socket import Socket
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class Session:
|
||||
"""Session between ADB client and ADB server"""
|
||||
|
||||
def __init__(self):
|
||||
self.server = "127.0.0.1", 5037
|
||||
self.timeout = 5
|
||||
self.device_id = None
|
||||
self.sock = Socket(self.server, self.timeout)
|
||||
|
||||
def __enter__(self) -> Session:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
pass
|
||||
|
||||
def request(self, cmd: str, reconnect: bool = False) -> Session:
|
||||
"""make a service request to ADB server, consult ADB sources for available services"""
|
||||
cmdbytes = cmd.encode()
|
||||
data = b"%04X%b" % (len(cmdbytes), cmdbytes)
|
||||
while self.timeout <= 10:
|
||||
try:
|
||||
self.sock.send(data).check_okay()
|
||||
return self
|
||||
except socket.timeout:
|
||||
logger.warning(f"socket.timeout: {self.timeout}s, +5s")
|
||||
self.timeout += 5
|
||||
self.sock = Socket(self.server, self.timeout)
|
||||
if reconnect:
|
||||
self.device(self.device_id)
|
||||
raise socket.timeout(f"server: {self.server}")
|
||||
|
||||
def response(self, recv_all: bool = False) -> bytes:
|
||||
"""receive response"""
|
||||
if recv_all:
|
||||
return self.sock.recv_all()
|
||||
else:
|
||||
return self.sock.recv_response()
|
||||
|
||||
def exec(self, cmd: str) -> bytes:
|
||||
"""exec: cmd"""
|
||||
if len(cmd) == 0:
|
||||
raise ValueError("no command specified for exec")
|
||||
return self.request("exec:" + cmd, True).response(True)
|
||||
|
||||
def shell(self, cmd: str) -> bytes:
|
||||
"""shell: cmd"""
|
||||
if len(cmd) == 0:
|
||||
raise ValueError("no command specified for shell")
|
||||
return self.request("shell:" + cmd, True).response(True)
|
||||
|
||||
def host(self, cmd: str) -> bytes:
|
||||
"""host: cmd"""
|
||||
if len(cmd) == 0:
|
||||
raise ValueError("no command specified for host")
|
||||
return self.request("host:" + cmd, True).response()
|
||||
|
||||
def run(self, cmd: str, recv_all: bool = False) -> bytes:
|
||||
"""run command"""
|
||||
if len(cmd) == 0:
|
||||
raise ValueError("no command specified")
|
||||
return self.request(cmd, True).response(recv_all)
|
||||
|
||||
def device(self, device_id: str = None) -> Session:
|
||||
"""switch to a device"""
|
||||
self.device_id = device_id
|
||||
if device_id is None:
|
||||
return self.request("host:transport-any")
|
||||
else:
|
||||
return self.request("host:transport:" + device_id)
|
||||
|
||||
def connect(self, throw_error: bool = False) -> None:
|
||||
"""connect device [ip:port]"""
|
||||
device = config.conf.adb
|
||||
resp = self.request(f"host:connect:{device}").response()
|
||||
logger.debug(f"{device}: {repr(resp)}")
|
||||
if throw_error and (b"unable" in resp or b"cannot" in resp):
|
||||
raise RuntimeError(repr(resp))
|
||||
|
||||
def disconnect(self, device: str, throw_error: bool = False) -> None:
|
||||
"""disconnect device [ip:port]"""
|
||||
resp = self.request(f"host:disconnect:{device}").response()
|
||||
logger.debug(f"{device}: {repr(resp)}")
|
||||
if throw_error and (b"unable" in resp or b"cannot" in resp):
|
||||
raise RuntimeError(repr(resp))
|
||||
|
||||
def devices_list(self) -> list[tuple[str, str]]:
|
||||
"""returns list of devices that the adb server knows"""
|
||||
resp = self.request("host:devices").response().decode(errors="ignore")
|
||||
devices = [tuple(line.split("\t")) for line in resp.splitlines()]
|
||||
logger.debug(devices)
|
||||
return devices
|
||||
|
||||
def push(self, target_path: str, target: bytes, mode=0o100755, mtime: int = None):
|
||||
"""push data to device"""
|
||||
self.request("sync:", True)
|
||||
request = b"%s,%d" % (target_path.encode(), mode)
|
||||
self.sock.send(b"SEND" + struct.pack("<I", len(request)) + request)
|
||||
buf = bytearray(65536 + 8)
|
||||
buf[0:4] = b"DATA"
|
||||
idx = 0
|
||||
while idx < len(target):
|
||||
content = target[idx : idx + 65536]
|
||||
content_len = len(content)
|
||||
idx += content_len
|
||||
buf[4:8] = struct.pack("<I", content_len)
|
||||
buf[8 : 8 + content_len] = content
|
||||
self.sock.sendall(bytes(buf[0 : 8 + content_len]))
|
||||
if mtime is None:
|
||||
mtime = int(time.time())
|
||||
self.sock.send(b"DONE" + struct.pack("<I", mtime))
|
||||
response = self.sock.recv_exactly(8)
|
||||
if response[:4] != b"OKAY":
|
||||
raise RuntimeError("push failed")
|
95
mower/utils/device/adb_client/socket.py
Normal file
95
mower/utils/device/adb_client/socket.py
Normal file
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class Socket:
|
||||
"""Connect ADB server with socket"""
|
||||
|
||||
def __init__(self, server: tuple[str, int], timeout: int) -> None:
|
||||
logger.debug(f"{server=} {timeout=}")
|
||||
try:
|
||||
self.sock = None
|
||||
self.sock = socket.create_connection(server, timeout=timeout)
|
||||
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except ConnectionRefusedError as e:
|
||||
logger.error(f"ConnectionRefusedError: {server}")
|
||||
raise e
|
||||
|
||||
def __enter__(self) -> Socket:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
pass
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""close socket"""
|
||||
self.sock and self.sock.close()
|
||||
self.sock = None
|
||||
|
||||
def recv_all(self, chunklen: int = 65536) -> bytes:
|
||||
data = []
|
||||
buf = bytearray(chunklen)
|
||||
view = memoryview(buf)
|
||||
pos = 0
|
||||
while True:
|
||||
if pos >= chunklen:
|
||||
data.append(buf)
|
||||
buf = bytearray(chunklen)
|
||||
view = memoryview(buf)
|
||||
pos = 0
|
||||
rcvlen = self.sock.recv_into(view)
|
||||
if rcvlen == 0:
|
||||
break
|
||||
view = view[rcvlen:]
|
||||
pos += rcvlen
|
||||
data.append(buf[:pos])
|
||||
return b"".join(data)
|
||||
|
||||
def recv_exactly(self, len: int) -> bytes:
|
||||
buf = bytearray(len)
|
||||
view = memoryview(buf)
|
||||
pos = 0
|
||||
while pos < len:
|
||||
rcvlen = self.sock.recv_into(view)
|
||||
if rcvlen == 0:
|
||||
break
|
||||
view = view[rcvlen:]
|
||||
pos += rcvlen
|
||||
if pos != len:
|
||||
raise EOFError("recv_exactly %d bytes failed" % len)
|
||||
return bytes(buf)
|
||||
|
||||
def recv_response(self) -> bytes:
|
||||
"""read a chunk of length indicated by 4 hex digits"""
|
||||
len = int(self.recv_exactly(4), 16)
|
||||
if len == 0:
|
||||
return b""
|
||||
return self.recv_exactly(len)
|
||||
|
||||
def check_okay(self) -> None:
|
||||
"""check if first 4 bytes is "OKAY" """
|
||||
result = self.recv_exactly(4)
|
||||
if result != b"OKAY":
|
||||
raise ConnectionError(self.recv_response())
|
||||
|
||||
def recv(self, len: int) -> bytes:
|
||||
return self.sock.recv(len)
|
||||
|
||||
def send(self, data: bytes) -> Socket:
|
||||
"""send data to server"""
|
||||
self.sock.send(data)
|
||||
return self
|
||||
|
||||
def sendall(self, data: bytes) -> Socket:
|
||||
"""send data to server"""
|
||||
self.sock.sendall(data)
|
||||
return self
|
||||
|
||||
def recv_into(self, buffer, nbytes: int) -> None:
|
||||
self.sock.recv_into(buffer, nbytes)
|
21
mower/utils/device/adb_client/utils.py
Normal file
21
mower/utils/device/adb_client/utils.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
import subprocess
|
||||
from typing import Union
|
||||
|
||||
from mower import __system__
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
def run_cmd(cmd: list[str], decode: bool = False) -> Union[bytes, str]:
|
||||
logger.debug(cmd)
|
||||
try:
|
||||
r = subprocess.check_output(
|
||||
cmd,
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if __system__ == "windows" else 0,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.debug(e.output)
|
||||
raise e
|
||||
if decode:
|
||||
return r.decode("utf8")
|
||||
return r
|
382
mower/utils/device/device.py
Normal file
382
mower/utils/device/device.py
Normal file
|
@ -0,0 +1,382 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from mower import __rootdir__, __system__
|
||||
from mower.utils import config
|
||||
from mower.utils.csleep import MowerExit, csleep
|
||||
from mower.utils.device.adb_client.core import Client as ADBClient
|
||||
from mower.utils.device.adb_client.session import Session
|
||||
from mower.utils.device.maatouch import MaaTouch
|
||||
from mower.utils.device.scrcpy import Scrcpy
|
||||
from mower.utils.image import bytes2img, img2bytes
|
||||
from mower.utils.log import logger, save_screenshot
|
||||
from mower.utils.network import get_new_port, is_port_in_use
|
||||
from mower.utils.simulator import restart_simulator
|
||||
|
||||
|
||||
class Device:
|
||||
"""Android Device"""
|
||||
|
||||
class Control:
|
||||
"""Android Device Control"""
|
||||
|
||||
def __init__(
|
||||
self, device: Device, client: ADBClient = None, touch_device: str = None
|
||||
) -> None:
|
||||
self.device = device
|
||||
self.maatouch = None
|
||||
self.scrcpy = None
|
||||
|
||||
if config.conf.touch_method == "maatouch":
|
||||
self.maatouch = MaaTouch(client)
|
||||
else:
|
||||
self.scrcpy = Scrcpy(client)
|
||||
|
||||
def tap(self, point: tuple[int, int]) -> None:
|
||||
if self.maatouch:
|
||||
self.maatouch.tap([point], self.device.display_frames())
|
||||
elif self.scrcpy:
|
||||
self.scrcpy.tap(point[0], point[1])
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
def swipe(
|
||||
self, start: tuple[int, int], end: tuple[int, int], duration: int
|
||||
) -> None:
|
||||
if self.maatouch:
|
||||
self.maatouch.swipe(
|
||||
[start, end], self.device.display_frames(), duration=duration
|
||||
)
|
||||
elif self.scrcpy:
|
||||
self.scrcpy.swipe(start[0], start[1], end[0], end[1], duration / 1000)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
def swipe_ext(
|
||||
self, points: list[tuple[int, int]], durations: list[int], up_wait: int
|
||||
) -> None:
|
||||
if self.maatouch:
|
||||
self.maatouch.swipe(
|
||||
points,
|
||||
self.device.display_frames(),
|
||||
duration=durations,
|
||||
up_wait=up_wait,
|
||||
)
|
||||
elif self.scrcpy:
|
||||
total = len(durations)
|
||||
for idx, (S, E, D) in enumerate(
|
||||
zip(points[:-1], points[1:], durations)
|
||||
):
|
||||
self.scrcpy.swipe(
|
||||
S[0],
|
||||
S[1],
|
||||
E[0],
|
||||
E[1],
|
||||
D / 1000,
|
||||
up_wait / 1000 if idx == total - 1 else 0,
|
||||
fall=idx == 0,
|
||||
lift=idx == total - 1,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
def __init__(
|
||||
self, device_id: str = None, connect: str = None, touch_device: str = None
|
||||
) -> None:
|
||||
self.device_id = device_id
|
||||
self.connect = connect
|
||||
self.touch_device = touch_device
|
||||
self.client = None
|
||||
self.control = None
|
||||
self.start()
|
||||
|
||||
def start(self) -> None:
|
||||
self.client = ADBClient(self.device_id, self.connect)
|
||||
self.control = Device.Control(self, self.client)
|
||||
|
||||
def run(self, cmd: str) -> Optional[bytes]:
|
||||
return self.client.run(cmd)
|
||||
|
||||
def launch(self) -> None:
|
||||
"""launch the application"""
|
||||
logger.info("明日方舟,启动!")
|
||||
|
||||
tap = config.conf.tap_to_launch_game.enable
|
||||
x = config.conf.tap_to_launch_game.x
|
||||
y = config.conf.tap_to_launch_game.y
|
||||
|
||||
if tap:
|
||||
self.run(f"input tap {x} {y}")
|
||||
else:
|
||||
self.run(f"am start -n {config.conf.APPNAME}/{config.APP_ACTIVITY_NAME}")
|
||||
|
||||
def exit(self) -> None:
|
||||
"""exit the application"""
|
||||
logger.info("退出游戏")
|
||||
self.run(f"am force-stop {config.conf.APPNAME}")
|
||||
|
||||
def send_keyevent(self, keycode: int) -> None:
|
||||
"""send a key event"""
|
||||
logger.debug(keycode)
|
||||
if self.control.scrcpy:
|
||||
self.control.scrcpy.control.send_keyevent(keycode)
|
||||
else:
|
||||
self.run(f"input keyevent {keycode}")
|
||||
|
||||
def send_text(self, text: str) -> None:
|
||||
"""send a text"""
|
||||
logger.debug(repr(text))
|
||||
text = text.replace('"', '\\"')
|
||||
command = f'input text "{text}"'
|
||||
self.run(command)
|
||||
|
||||
def get_droidcast_classpath(self) -> str | None:
|
||||
# TODO: 退出时(并非结束mower线程时)关闭DroidCast进程、取消ADB转发
|
||||
try:
|
||||
out = self.client.cmd_shell("pm path com.rayworks.droidcast", decode=True)
|
||||
except Exception:
|
||||
logger.exception("无法获取CLASSPATH")
|
||||
return None
|
||||
prefix = "package:"
|
||||
postfix = ".apk"
|
||||
beg = out.index(prefix, 0)
|
||||
end = out.rfind(postfix)
|
||||
class_path = out[beg + len(prefix) : (end + len(postfix))].strip()
|
||||
class_path = "CLASSPATH=" + class_path
|
||||
logger.info(f"成功获取CLASSPATH:{class_path}")
|
||||
return class_path
|
||||
|
||||
def start_droidcast(self) -> bool:
|
||||
class_path = self.get_droidcast_classpath()
|
||||
if not class_path:
|
||||
logger.info("安装DroidCast")
|
||||
apk_path = f"{__rootdir__}/vendor/droidcast/DroidCast-debug-1.2.1.apk"
|
||||
out = self.client.cmd(["install", apk_path], decode=True)
|
||||
if "Success" in out:
|
||||
logger.info("DroidCast安装完成,获取CLASSPATH")
|
||||
else:
|
||||
logger.error(f"DroidCast安装失败:{out}")
|
||||
return False
|
||||
class_path = self.get_droidcast_classpath()
|
||||
if not class_path:
|
||||
logger.error(f"无法获取CLASSPATH:{out}")
|
||||
return False
|
||||
port = config.droidcast.port
|
||||
if port != 0 and is_port_in_use(port):
|
||||
try:
|
||||
occupied_by_adb_forward = False
|
||||
forward_list = self.client.cmd("forward --list", True).strip().split()
|
||||
for host, pc_port, android_port in forward_list:
|
||||
# 127.0.0.1:5555 tcp:60579 tcp:60579
|
||||
if pc_port != android_port:
|
||||
# 不是咱转发的,别乱动
|
||||
continue
|
||||
if pc_port == f"tcp:{port}":
|
||||
occupied_by_adb_forward = True
|
||||
break
|
||||
if not occupied_by_adb_forward:
|
||||
port = 0
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
if port == 0:
|
||||
port = get_new_port()
|
||||
config.droidcast.port = port
|
||||
logger.info(f"更新DroidCast端口为{port}")
|
||||
else:
|
||||
logger.info(f"保持DroidCast端口为{port}")
|
||||
self.client.cmd(f"forward tcp:{port} tcp:{port}")
|
||||
logger.info("ADB端口转发成功,启动DroidCast")
|
||||
if config.droidcast.process is not None:
|
||||
config.droidcast.process.terminate()
|
||||
process = self.client.process(
|
||||
class_path,
|
||||
[
|
||||
"app_process",
|
||||
"/",
|
||||
"com.rayworks.droidcast.Main",
|
||||
f"--port={port}",
|
||||
],
|
||||
)
|
||||
config.droidcast.process = process
|
||||
return True
|
||||
|
||||
def screencap(self) -> bytes:
|
||||
start_time = datetime.now()
|
||||
min_time = config.screenshot_time + timedelta(
|
||||
milliseconds=config.conf.screenshot_interval
|
||||
)
|
||||
delta = (min_time - start_time).total_seconds()
|
||||
if delta > 0:
|
||||
time.sleep(delta)
|
||||
start_time = min_time
|
||||
|
||||
if config.conf.droidcast.enable:
|
||||
session = config.droidcast.session
|
||||
while True:
|
||||
try:
|
||||
port = config.droidcast.port
|
||||
url = f"http://127.0.0.1:{port}/screenshot"
|
||||
logger.debug(f"GET {url}")
|
||||
r = session.get(url)
|
||||
img = bytes2img(r.content)
|
||||
if config.conf.droidcast.rotate:
|
||||
img = cv2.rotate(img, cv2.ROTATE_180)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
restart_simulator()
|
||||
self.client.check_server_alive()
|
||||
Session().connect()
|
||||
self.start_droidcast()
|
||||
if config.conf.touch_method == "scrcpy":
|
||||
self.control.scrcpy = Scrcpy(self.client)
|
||||
elif config.conf.custom_screenshot.enable:
|
||||
command = config.conf.custom_screenshot.command
|
||||
while True:
|
||||
try:
|
||||
data = subprocess.check_output(
|
||||
command,
|
||||
shell=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW
|
||||
if __system__ == "windows"
|
||||
else 0,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
restart_simulator()
|
||||
self.client.check_server_alive()
|
||||
Session().connect()
|
||||
if config.conf.touch_method == "scrcpy":
|
||||
self.control.scrcpy = Scrcpy(self.client)
|
||||
img = bytes2img(data)
|
||||
else:
|
||||
command = "screencap 2>/dev/null | gzip -1"
|
||||
while True:
|
||||
try:
|
||||
resp = self.run(command)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
restart_simulator()
|
||||
self.client.check_server_alive()
|
||||
Session().connect()
|
||||
if config.conf.touch_method == "scrcpy":
|
||||
self.control.scrcpy = Scrcpy(self.client)
|
||||
data = gzip.decompress(resp)
|
||||
array = np.frombuffer(data[-1920 * 1080 * 4 :], np.uint8).reshape(
|
||||
1080, 1920, 4
|
||||
)
|
||||
img = cv2.cvtColor(array, cv2.COLOR_RGBA2RGB)
|
||||
|
||||
screencap = img2bytes(img)
|
||||
save_screenshot(screencap)
|
||||
|
||||
stop_time = datetime.now()
|
||||
config.screenshot_time = stop_time
|
||||
interval = (stop_time - start_time).total_seconds() * 1000
|
||||
if config.screenshot_avg is None:
|
||||
config.screenshot_avg = interval
|
||||
else:
|
||||
config.screenshot_avg = config.screenshot_avg * 0.9 + interval * 0.1
|
||||
if config.screenshot_count >= 100:
|
||||
config.screenshot_count = 0
|
||||
logger.info(
|
||||
f"截图用时{interval:.0f}ms 平均用时{config.screenshot_avg:.0f}ms"
|
||||
)
|
||||
else:
|
||||
config.screenshot_count += 1
|
||||
|
||||
return img
|
||||
|
||||
def current_focus(self) -> str:
|
||||
"""detect current focus app"""
|
||||
command = "dumpsys window | grep mCurrentFocus"
|
||||
line = self.run(command).decode("utf8")
|
||||
return line.strip()[:-1].split(" ")[-1]
|
||||
|
||||
def display_frames(self) -> tuple[int, int, int]:
|
||||
"""get display frames if in compatibility mode"""
|
||||
if not config.MNT_COMPATIBILITY_MODE:
|
||||
return None
|
||||
|
||||
command = "dumpsys window | grep DisplayFrames"
|
||||
line = self.run(command).decode("utf8")
|
||||
""" eg. DisplayFrames w=1920 h=1080 r=3 """
|
||||
res = line.strip().replace("=", " ").split(" ")
|
||||
return int(res[2]), int(res[4]), int(res[6])
|
||||
|
||||
def tap(self, point: tuple[int, int]) -> None:
|
||||
"""tap"""
|
||||
logger.debug(point)
|
||||
self.control.tap(point)
|
||||
|
||||
def swipe(
|
||||
self, start: tuple[int, int], end: tuple[int, int], duration: int = 100
|
||||
) -> None:
|
||||
"""swipe"""
|
||||
logger.debug(f"{start=} {end=} {duration=}")
|
||||
self.control.swipe(start, end, duration)
|
||||
|
||||
def swipe_ext(
|
||||
self, points: list[tuple[int, int]], durations: list[int], up_wait: int = 200
|
||||
) -> None:
|
||||
"""swipe_ext"""
|
||||
logger.debug(f"{points=} {durations=} {up_wait=}")
|
||||
self.control.swipe_ext(points, durations, up_wait)
|
||||
|
||||
def check_current_focus(self) -> bool:
|
||||
"""check if the application is in the foreground"""
|
||||
update = False
|
||||
while True:
|
||||
try:
|
||||
focus = self.current_focus()
|
||||
if focus not in [
|
||||
f"{config.conf.APPNAME}/{config.APP_ACTIVITY_NAME}",
|
||||
"com.hypergryph.arknights.bilibili/com.gsc.welcome.WelcomeActivity",
|
||||
]:
|
||||
self.exit() # 防止应用卡死
|
||||
self.launch()
|
||||
csleep(10)
|
||||
update = True
|
||||
return update
|
||||
except MowerExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
restart_simulator()
|
||||
self.client.check_server_alive()
|
||||
Session().connect()
|
||||
if config.conf.droidcast.enable:
|
||||
self.start_droidcast()
|
||||
if config.conf.touch_method == "scrcpy":
|
||||
self.control.scrcpy = Scrcpy(self.client)
|
||||
update = True
|
||||
|
||||
def check_device_screen(self) -> bool:
|
||||
"""检查分辨率和DPI
|
||||
|
||||
Returns:
|
||||
设置是否正确
|
||||
"""
|
||||
res_output = self.client.cmd_shell("wm size", True).strip()
|
||||
dpi_output = self.client.cmd_shell("wm density", True).strip()
|
||||
logger.debug(f"{res_output} {dpi_output}".replace("\n", " "))
|
||||
|
||||
physical_res, _, override_res = res_output.partition("Override")
|
||||
physical_dpi, _, override_dpi = dpi_output.partition("Override")
|
||||
res = (override_res or physical_res).partition("size:")[2].strip()
|
||||
dpi = (override_dpi or physical_dpi).partition("density:")[2].strip()
|
||||
|
||||
if res in ["1920x1080", "1080x1920"] and dpi == "280":
|
||||
return True
|
||||
logger.error("Mower仅支持1920x1080分辨率、280DPI,请修改模拟器设置")
|
||||
return False
|
3
mower/utils/device/maatouch/__init__.py
Normal file
3
mower/utils/device/maatouch/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from mower.utils.device.maatouch.core import Client as MaaTouch
|
||||
|
||||
__all__ = ["MaaTouch"]
|
50
mower/utils/device/maatouch/command.py
Normal file
50
mower/utils/device/maatouch/command.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
import time
|
||||
|
||||
from mower.utils.device.maatouch.session import Session
|
||||
from mower.utils.log import logger
|
||||
|
||||
DEFAULT_DELAY = 0.05
|
||||
|
||||
|
||||
class CommandBuilder:
|
||||
"""Build command str for minitouch"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.content = ""
|
||||
self.delay = 0
|
||||
|
||||
def append(self, new_content: str) -> None:
|
||||
self.content += new_content + "\n"
|
||||
|
||||
def commit(self) -> None:
|
||||
"""add minitouch command: 'c\n'"""
|
||||
self.append("c")
|
||||
|
||||
def wait(self, ms: int) -> None:
|
||||
"""add minitouch command: 'w <ms>\n'"""
|
||||
self.append(f"w {ms}")
|
||||
self.delay += ms
|
||||
|
||||
def up(self, contact_id: int) -> None:
|
||||
"""add minitouch command: 'u <contact_id>\n'"""
|
||||
self.append(f"u {contact_id}")
|
||||
|
||||
def down(self, contact_id: int, x: int, y: int, pressure: int) -> None:
|
||||
"""add minitouch command: 'd <contact_id> <x> <y> <pressure>\n'"""
|
||||
self.append(f"d {contact_id} {x} {y} {pressure}")
|
||||
|
||||
def move(self, contact_id: int, x: int, y: int, pressure: int) -> None:
|
||||
"""add minitouch command: 'm <contact_id> <x> <y> <pressure>\n'"""
|
||||
self.append(f"m {contact_id} {x} {y} {pressure}")
|
||||
|
||||
def publish(self, session: Session):
|
||||
"""apply current commands to device"""
|
||||
self.commit()
|
||||
logger.debug(self.content.replace("\n", "\\n"))
|
||||
session.send(self.content)
|
||||
time.sleep(self.delay / 1000 + DEFAULT_DELAY)
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
"""clear current commands"""
|
||||
self.content = ""
|
214
mower/utils/device/maatouch/core.py
Normal file
214
mower/utils/device/maatouch/core.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
from typing import Union
|
||||
|
||||
from mower import __rootdir__
|
||||
from mower.utils import config
|
||||
from mower.utils.device.adb_client.core import Client as ADBClient
|
||||
from mower.utils.device.maatouch.command import CommandBuilder
|
||||
from mower.utils.device.maatouch.session import Session
|
||||
from mower.utils.log import logger
|
||||
|
||||
MNT_PATH = "/data/local/tmp/maatouch"
|
||||
|
||||
|
||||
class Client:
|
||||
"""Use maatouch to control Android devices easily"""
|
||||
|
||||
def __init__(self, client: ADBClient) -> None:
|
||||
self.client = client
|
||||
self.start()
|
||||
|
||||
def start(self) -> None:
|
||||
self.__install()
|
||||
|
||||
def __del__(self) -> None:
|
||||
pass
|
||||
|
||||
def __install(self) -> None:
|
||||
"""install maatouch for android devices"""
|
||||
if self.__is_mnt_existed():
|
||||
logger.debug(f"maatouch already existed in {self.client.device_id}")
|
||||
else:
|
||||
self.__download_mnt()
|
||||
|
||||
def __is_mnt_existed(self) -> bool:
|
||||
"""check if maatouch is existed in the device"""
|
||||
file_list = self.client.cmd_shell("ls /data/local/tmp", True)
|
||||
return "maatouch" in file_list
|
||||
|
||||
def __download_mnt(self) -> None:
|
||||
"""download maatouch"""
|
||||
mnt_path = f"{__rootdir__}/vendor/maatouch/maatouch"
|
||||
|
||||
# push and grant
|
||||
self.client.cmd_push(mnt_path, MNT_PATH)
|
||||
logger.info("maatouch already installed in {MNT_PATH}")
|
||||
|
||||
def check_adb_alive(self) -> bool:
|
||||
"""check if adb server alive"""
|
||||
return self.client.check_server_alive()
|
||||
|
||||
def convert_coordinate(
|
||||
self,
|
||||
point: tuple[int, int],
|
||||
display_frames: tuple[int, int, int],
|
||||
max_x: int,
|
||||
max_y: int,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
check compatibility mode and convert coordinate
|
||||
see details: https://github.com/Konano/arknights-mower/issues/85
|
||||
"""
|
||||
if not config.MNT_COMPATIBILITY_MODE:
|
||||
return point
|
||||
x, y = point
|
||||
w, h, r = display_frames
|
||||
if r == 1:
|
||||
return [(h - y) * max_x // h, x * max_y // w]
|
||||
if r == 3:
|
||||
return [y * max_x // h, (w - x) * max_y // w]
|
||||
logger.debug(
|
||||
f"warning: unexpected rotation parameter: display_frames({w}, {h}, {r})"
|
||||
)
|
||||
return point
|
||||
|
||||
def tap(
|
||||
self,
|
||||
points: list[tuple[int, int]],
|
||||
display_frames: tuple[int, int, int],
|
||||
pressure: int = 100,
|
||||
duration: int = None,
|
||||
lift: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
tap on screen with pressure and duration
|
||||
|
||||
:param points: list[int], look like [(x1, y1), (x2, y2), ...]
|
||||
:param display_frames: tuple[int, int, int], which means [weight, high, rotation] by "adb shell dumpsys window | grep DisplayFrames"
|
||||
:param pressure: default to 100
|
||||
:param duration: in milliseconds
|
||||
:param lift: if True, "lift" the touch point
|
||||
"""
|
||||
self.check_adb_alive()
|
||||
|
||||
builder = CommandBuilder()
|
||||
points = [list(map(int, point)) for point in points]
|
||||
with Session(self.client) as conn:
|
||||
for id, point in enumerate(points):
|
||||
x, y = self.convert_coordinate(
|
||||
point, display_frames, int(conn.max_x), int(conn.max_y)
|
||||
)
|
||||
builder.down(id, x, y, pressure)
|
||||
builder.commit()
|
||||
|
||||
if duration:
|
||||
builder.wait(duration)
|
||||
builder.commit()
|
||||
|
||||
if lift:
|
||||
for id in range(len(points)):
|
||||
builder.up(id)
|
||||
|
||||
builder.publish(conn)
|
||||
|
||||
def __swipe(
|
||||
self,
|
||||
points: list[tuple[int, int]],
|
||||
display_frames: tuple[int, int, int],
|
||||
pressure: int = 100,
|
||||
duration: Union[list[int], int] = None,
|
||||
up_wait: int = 0,
|
||||
fall: bool = True,
|
||||
lift: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
swipe between points one by one, with pressure and duration
|
||||
|
||||
:param points: list, look like [(x1, y1), (x2, y2), ...]
|
||||
:param display_frames: tuple[int, int, int], which means [weight, high, rotation] by "adb shell dumpsys window | grep DisplayFrames"
|
||||
:param pressure: default to 100
|
||||
:param duration: in milliseconds
|
||||
:param up_wait: in milliseconds
|
||||
:param fall: if True, "fall" the first touch point
|
||||
:param lift: if True, "lift" the last touch point
|
||||
"""
|
||||
self.check_adb_alive()
|
||||
|
||||
points = [list(map(int, point)) for point in points]
|
||||
if not isinstance(duration, list):
|
||||
duration = [duration] * (len(points) - 1)
|
||||
assert len(duration) + 1 == len(points)
|
||||
|
||||
builder = CommandBuilder()
|
||||
with Session(self.client) as conn:
|
||||
if fall:
|
||||
x, y = self.convert_coordinate(
|
||||
points[0], display_frames, int(conn.max_x), int(conn.max_y)
|
||||
)
|
||||
builder.down(0, x, y, pressure)
|
||||
builder.publish(conn)
|
||||
|
||||
for idx, point in enumerate(points[1:]):
|
||||
x, y = self.convert_coordinate(
|
||||
point, display_frames, int(conn.max_x), int(conn.max_y)
|
||||
)
|
||||
builder.move(0, x, y, pressure)
|
||||
if duration[idx - 1]:
|
||||
builder.wait(duration[idx - 1])
|
||||
builder.commit()
|
||||
builder.publish(conn)
|
||||
|
||||
if lift:
|
||||
builder.up(0)
|
||||
if up_wait:
|
||||
builder.wait(up_wait)
|
||||
builder.publish(conn)
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
points: list[tuple[int, int]],
|
||||
display_frames: tuple[int, int, int],
|
||||
pressure: int = 100,
|
||||
duration: Union[list[int], int] = None,
|
||||
up_wait: int = 0,
|
||||
part: int = 10,
|
||||
fall: bool = True,
|
||||
lift: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
swipe between points one by one, with pressure and duration
|
||||
it will split distance between points into pieces
|
||||
|
||||
:param points: list, look like [(x1, y1), (x2, y2), ...]
|
||||
:param display_frames: tuple[int, int, int], which means [weight, high, rotation] by "adb shell dumpsys window | grep DisplayFrames"
|
||||
:param pressure: default to 100
|
||||
:param duration: in milliseconds
|
||||
:param up_wait: in milliseconds
|
||||
:param part: default to 10
|
||||
:param fall: if True, "fall" the first touch point
|
||||
:param lift: if True, "lift" the last touch point
|
||||
"""
|
||||
points = [list(map(int, point)) for point in points]
|
||||
if not isinstance(duration, list):
|
||||
duration = [duration] * (len(points) - 1)
|
||||
assert len(duration) + 1 == len(points)
|
||||
|
||||
new_points = [points[0]]
|
||||
new_duration = []
|
||||
for id in range(1, len(points)):
|
||||
pre_point = points[id - 1]
|
||||
cur_point = points[id]
|
||||
offset = (
|
||||
(cur_point[0] - pre_point[0]) // part,
|
||||
(cur_point[1] - pre_point[1]) // part,
|
||||
)
|
||||
new_points += [
|
||||
(pre_point[0] + i * offset[0], pre_point[1] + i * offset[1])
|
||||
for i in range(1, part + 1)
|
||||
]
|
||||
if duration[id - 1] is None:
|
||||
new_duration += [None] * part
|
||||
else:
|
||||
new_duration += [duration[id - 1] // part] * part
|
||||
self.__swipe(
|
||||
new_points, display_frames, pressure, new_duration, up_wait, fall, lift
|
||||
)
|
53
mower/utils/device/maatouch/session.py
Normal file
53
mower/utils/device/maatouch/session.py
Normal file
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from mower import __system__
|
||||
from mower.utils.device.adb_client.core import Client as ADBClient
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, client: ADBClient) -> None:
|
||||
self.process = subprocess.Popen(
|
||||
[
|
||||
client.adb_bin,
|
||||
"-s",
|
||||
client.device_id,
|
||||
"shell",
|
||||
"CLASSPATH=/data/local/tmp/maatouch",
|
||||
"app_process",
|
||||
"/",
|
||||
"com.shxyke.MaaTouch.App",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if __system__ == "windows" else 0,
|
||||
)
|
||||
|
||||
# ^ <max-contacts> <max-x> <max-y> <max-pressure>
|
||||
_, max_contacts, max_x, max_y, max_pressure, *_ = (
|
||||
self.process.stdout.readline().strip().split(" ")
|
||||
)
|
||||
self.max_contacts = max_contacts
|
||||
self.max_x = max_x
|
||||
self.max_y = max_y
|
||||
self.max_pressure = max_pressure
|
||||
|
||||
# $ <pid>
|
||||
_, pid = self.process.stdout.readline().strip().split(" ")
|
||||
self.pid = pid
|
||||
|
||||
logger.debug(f"{self.pid=}")
|
||||
logger.debug(f"{max_contacts=} {max_x=} {max_y=} {max_pressure=}")
|
||||
|
||||
def __enter__(self) -> Session:
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, exc_traceback) -> None:
|
||||
self.process.terminate()
|
||||
|
||||
def send(self, content: str):
|
||||
self.process.stdin.write(content)
|
||||
self.process.stdin.flush()
|
20
mower/utils/device/scrcpy/LICENSE
Normal file
20
mower/utils/device/scrcpy/LICENSE
Normal file
|
@ -0,0 +1,20 @@
|
|||
Copyright (c) 2021-2021 Lengyue and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
3
mower/utils/device/scrcpy/__init__.py
Normal file
3
mower/utils/device/scrcpy/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
from .core import Client as Scrcpy
|
||||
|
||||
__all__ = ["Scrcpy"]
|
329
mower/utils/device/scrcpy/const.py
Normal file
329
mower/utils/device/scrcpy/const.py
Normal file
|
@ -0,0 +1,329 @@
|
|||
"""
|
||||
This module includes all consts used in this project
|
||||
"""
|
||||
|
||||
# Action
|
||||
ACTION_DOWN = 0
|
||||
ACTION_UP = 1
|
||||
ACTION_MOVE = 2
|
||||
|
||||
# KeyCode
|
||||
KEYCODE_UNKNOWN = 0
|
||||
KEYCODE_SOFT_LEFT = 1
|
||||
KEYCODE_SOFT_RIGHT = 2
|
||||
KEYCODE_HOME = 3
|
||||
KEYCODE_BACK = 4
|
||||
KEYCODE_CALL = 5
|
||||
KEYCODE_ENDCALL = 6
|
||||
KEYCODE_0 = 7
|
||||
KEYCODE_1 = 8
|
||||
KEYCODE_2 = 9
|
||||
KEYCODE_3 = 10
|
||||
KEYCODE_4 = 11
|
||||
KEYCODE_5 = 12
|
||||
KEYCODE_6 = 13
|
||||
KEYCODE_7 = 14
|
||||
KEYCODE_8 = 15
|
||||
KEYCODE_9 = 16
|
||||
KEYCODE_STAR = 17
|
||||
KEYCODE_POUND = 18
|
||||
KEYCODE_DPAD_UP = 19
|
||||
KEYCODE_DPAD_DOWN = 20
|
||||
KEYCODE_DPAD_LEFT = 21
|
||||
KEYCODE_DPAD_RIGHT = 22
|
||||
KEYCODE_DPAD_CENTER = 23
|
||||
KEYCODE_VOLUME_UP = 24
|
||||
KEYCODE_VOLUME_DOWN = 25
|
||||
KEYCODE_POWER = 26
|
||||
KEYCODE_CAMERA = 27
|
||||
KEYCODE_CLEAR = 28
|
||||
KEYCODE_A = 29
|
||||
KEYCODE_B = 30
|
||||
KEYCODE_C = 31
|
||||
KEYCODE_D = 32
|
||||
KEYCODE_E = 33
|
||||
KEYCODE_F = 34
|
||||
KEYCODE_G = 35
|
||||
KEYCODE_H = 36
|
||||
KEYCODE_I = 37
|
||||
KEYCODE_J = 38
|
||||
KEYCODE_K = 39
|
||||
KEYCODE_L = 40
|
||||
KEYCODE_M = 41
|
||||
KEYCODE_N = 42
|
||||
KEYCODE_O = 43
|
||||
KEYCODE_P = 44
|
||||
KEYCODE_Q = 45
|
||||
KEYCODE_R = 46
|
||||
KEYCODE_S = 47
|
||||
KEYCODE_T = 48
|
||||
KEYCODE_U = 49
|
||||
KEYCODE_V = 50
|
||||
KEYCODE_W = 51
|
||||
KEYCODE_X = 52
|
||||
KEYCODE_Y = 53
|
||||
KEYCODE_Z = 54
|
||||
KEYCODE_COMMA = 55
|
||||
KEYCODE_PERIOD = 56
|
||||
KEYCODE_ALT_LEFT = 57
|
||||
KEYCODE_ALT_RIGHT = 58
|
||||
KEYCODE_SHIFT_LEFT = 59
|
||||
KEYCODE_SHIFT_RIGHT = 60
|
||||
KEYCODE_TAB = 61
|
||||
KEYCODE_SPACE = 62
|
||||
KEYCODE_SYM = 63
|
||||
KEYCODE_EXPLORER = 64
|
||||
KEYCODE_ENVELOPE = 65
|
||||
KEYCODE_ENTER = 66
|
||||
KEYCODE_DEL = 67
|
||||
KEYCODE_GRAVE = 68
|
||||
KEYCODE_MINUS = 69
|
||||
KEYCODE_EQUALS = 70
|
||||
KEYCODE_LEFT_BRACKET = 71
|
||||
KEYCODE_RIGHT_BRACKET = 72
|
||||
KEYCODE_BACKSLASH = 73
|
||||
KEYCODE_SEMICOLON = 74
|
||||
KEYCODE_APOSTROPHE = 75
|
||||
KEYCODE_SLASH = 76
|
||||
KEYCODE_AT = 77
|
||||
KEYCODE_NUM = 78
|
||||
KEYCODE_HEADSETHOOK = 79
|
||||
KEYCODE_PLUS = 81
|
||||
KEYCODE_MENU = 82
|
||||
KEYCODE_NOTIFICATION = 83
|
||||
KEYCODE_SEARCH = 84
|
||||
KEYCODE_MEDIA_PLAY_PAUSE = 85
|
||||
KEYCODE_MEDIA_STOP = 86
|
||||
KEYCODE_MEDIA_NEXT = 87
|
||||
KEYCODE_MEDIA_PREVIOUS = 88
|
||||
KEYCODE_MEDIA_REWIND = 89
|
||||
KEYCODE_MEDIA_FAST_FORWARD = 90
|
||||
KEYCODE_MUTE = 91
|
||||
KEYCODE_PAGE_UP = 92
|
||||
KEYCODE_PAGE_DOWN = 93
|
||||
KEYCODE_BUTTON_A = 96
|
||||
KEYCODE_BUTTON_B = 97
|
||||
KEYCODE_BUTTON_C = 98
|
||||
KEYCODE_BUTTON_X = 99
|
||||
KEYCODE_BUTTON_Y = 100
|
||||
KEYCODE_BUTTON_Z = 101
|
||||
KEYCODE_BUTTON_L1 = 102
|
||||
KEYCODE_BUTTON_R1 = 103
|
||||
KEYCODE_BUTTON_L2 = 104
|
||||
KEYCODE_BUTTON_R2 = 105
|
||||
KEYCODE_BUTTON_THUMBL = 106
|
||||
KEYCODE_BUTTON_THUMBR = 107
|
||||
KEYCODE_BUTTON_START = 108
|
||||
KEYCODE_BUTTON_SELECT = 109
|
||||
KEYCODE_BUTTON_MODE = 110
|
||||
KEYCODE_ESCAPE = 111
|
||||
KEYCODE_FORWARD_DEL = 112
|
||||
KEYCODE_CTRL_LEFT = 113
|
||||
KEYCODE_CTRL_RIGHT = 114
|
||||
KEYCODE_CAPS_LOCK = 115
|
||||
KEYCODE_SCROLL_LOCK = 116
|
||||
KEYCODE_META_LEFT = 117
|
||||
KEYCODE_META_RIGHT = 118
|
||||
KEYCODE_FUNCTION = 119
|
||||
KEYCODE_SYSRQ = 120
|
||||
KEYCODE_BREAK = 121
|
||||
KEYCODE_MOVE_HOME = 122
|
||||
KEYCODE_MOVE_END = 123
|
||||
KEYCODE_INSERT = 124
|
||||
KEYCODE_FORWARD = 125
|
||||
KEYCODE_MEDIA_PLAY = 126
|
||||
KEYCODE_MEDIA_PAUSE = 127
|
||||
KEYCODE_MEDIA_CLOSE = 128
|
||||
KEYCODE_MEDIA_EJECT = 129
|
||||
KEYCODE_MEDIA_RECORD = 130
|
||||
KEYCODE_F1 = 131
|
||||
KEYCODE_F2 = 132
|
||||
KEYCODE_F3 = 133
|
||||
KEYCODE_F4 = 134
|
||||
KEYCODE_F5 = 135
|
||||
KEYCODE_F6 = 136
|
||||
KEYCODE_F7 = 137
|
||||
KEYCODE_F8 = 138
|
||||
KEYCODE_F9 = 139
|
||||
KEYCODE_F10 = 140
|
||||
KEYCODE_F11 = 141
|
||||
KEYCODE_F12 = 142
|
||||
KEYCODE_NUM_LOCK = 143
|
||||
KEYCODE_NUMPAD_0 = 144
|
||||
KEYCODE_NUMPAD_1 = 145
|
||||
KEYCODE_NUMPAD_2 = 146
|
||||
KEYCODE_NUMPAD_3 = 147
|
||||
KEYCODE_NUMPAD_4 = 148
|
||||
KEYCODE_NUMPAD_5 = 149
|
||||
KEYCODE_NUMPAD_6 = 150
|
||||
KEYCODE_NUMPAD_7 = 151
|
||||
KEYCODE_NUMPAD_8 = 152
|
||||
KEYCODE_NUMPAD_9 = 153
|
||||
KEYCODE_NUMPAD_DIVIDE = 154
|
||||
KEYCODE_NUMPAD_MULTIPLY = 155
|
||||
KEYCODE_NUMPAD_SUBTRACT = 156
|
||||
KEYCODE_NUMPAD_ADD = 157
|
||||
KEYCODE_NUMPAD_DOT = 158
|
||||
KEYCODE_NUMPAD_COMMA = 159
|
||||
KEYCODE_NUMPAD_ENTER = 160
|
||||
KEYCODE_NUMPAD_EQUALS = 161
|
||||
KEYCODE_NUMPAD_LEFT_PAREN = 162
|
||||
KEYCODE_NUMPAD_RIGHT_PAREN = 163
|
||||
KEYCODE_VOLUME_MUTE = 164
|
||||
KEYCODE_INFO = 165
|
||||
KEYCODE_CHANNEL_UP = 166
|
||||
KEYCODE_CHANNEL_DOWN = 167
|
||||
KEYCODE_ZOOM_IN = 168
|
||||
KEYCODE_ZOOM_OUT = 169
|
||||
KEYCODE_TV = 170
|
||||
KEYCODE_WINDOW = 171
|
||||
KEYCODE_GUIDE = 172
|
||||
KEYCODE_DVR = 173
|
||||
KEYCODE_BOOKMARK = 174
|
||||
KEYCODE_CAPTIONS = 175
|
||||
KEYCODE_SETTINGS = 176
|
||||
KEYCODE_TV_POWER = 177
|
||||
KEYCODE_TV_INPUT = 178
|
||||
KEYCODE_STB_POWER = 179
|
||||
KEYCODE_STB_INPUT = 180
|
||||
KEYCODE_AVR_POWER = 181
|
||||
KEYCODE_AVR_INPUT = 182
|
||||
KEYCODE_PROG_RED = 183
|
||||
KEYCODE_PROG_GREEN = 184
|
||||
KEYCODE_PROG_YELLOW = 185
|
||||
KEYCODE_PROG_BLUE = 186
|
||||
KEYCODE_APP_SWITCH = 187
|
||||
KEYCODE_BUTTON_1 = 188
|
||||
KEYCODE_BUTTON_2 = 189
|
||||
KEYCODE_BUTTON_3 = 190
|
||||
KEYCODE_BUTTON_4 = 191
|
||||
KEYCODE_BUTTON_5 = 192
|
||||
KEYCODE_BUTTON_6 = 193
|
||||
KEYCODE_BUTTON_7 = 194
|
||||
KEYCODE_BUTTON_8 = 195
|
||||
KEYCODE_BUTTON_9 = 196
|
||||
KEYCODE_BUTTON_10 = 197
|
||||
KEYCODE_BUTTON_11 = 198
|
||||
KEYCODE_BUTTON_12 = 199
|
||||
KEYCODE_BUTTON_13 = 200
|
||||
KEYCODE_BUTTON_14 = 201
|
||||
KEYCODE_BUTTON_15 = 202
|
||||
KEYCODE_BUTTON_16 = 203
|
||||
KEYCODE_LANGUAGE_SWITCH = 204
|
||||
KEYCODE_MANNER_MODE = 205
|
||||
KEYCODE_3D_MODE = 206
|
||||
KEYCODE_CONTACTS = 207
|
||||
KEYCODE_CALENDAR = 208
|
||||
KEYCODE_MUSIC = 209
|
||||
KEYCODE_CALCULATOR = 210
|
||||
KEYCODE_ZENKAKU_HANKAKU = 211
|
||||
KEYCODE_EISU = 212
|
||||
KEYCODE_MUHENKAN = 213
|
||||
KEYCODE_HENKAN = 214
|
||||
KEYCODE_KATAKANA_HIRAGANA = 215
|
||||
KEYCODE_YEN = 216
|
||||
KEYCODE_RO = 217
|
||||
KEYCODE_KANA = 218
|
||||
KEYCODE_ASSIST = 219
|
||||
KEYCODE_BRIGHTNESS_DOWN = 220
|
||||
KEYCODE_BRIGHTNESS_UP = 221
|
||||
KEYCODE_MEDIA_AUDIO_TRACK = 222
|
||||
KEYCODE_SLEEP = 223
|
||||
KEYCODE_WAKEUP = 224
|
||||
KEYCODE_PAIRING = 225
|
||||
KEYCODE_MEDIA_TOP_MENU = 226
|
||||
KEYCODE_11 = 227
|
||||
KEYCODE_12 = 228
|
||||
KEYCODE_LAST_CHANNEL = 229
|
||||
KEYCODE_TV_DATA_SERVICE = 230
|
||||
KEYCODE_VOICE_ASSIST = 231
|
||||
KEYCODE_TV_RADIO_SERVICE = 232
|
||||
KEYCODE_TV_TELETEXT = 233
|
||||
KEYCODE_TV_NUMBER_ENTRY = 234
|
||||
KEYCODE_TV_TERRESTRIAL_ANALOG = 235
|
||||
KEYCODE_TV_TERRESTRIAL_DIGITAL = 236
|
||||
KEYCODE_TV_SATELLITE = 237
|
||||
KEYCODE_TV_SATELLITE_BS = 238
|
||||
KEYCODE_TV_SATELLITE_CS = 239
|
||||
KEYCODE_TV_SATELLITE_SERVICE = 240
|
||||
KEYCODE_TV_NETWORK = 241
|
||||
KEYCODE_TV_ANTENNA_CABLE = 242
|
||||
KEYCODE_TV_INPUT_HDMI_1 = 243
|
||||
KEYCODE_TV_INPUT_HDMI_2 = 244
|
||||
KEYCODE_TV_INPUT_HDMI_3 = 245
|
||||
KEYCODE_TV_INPUT_HDMI_4 = 246
|
||||
KEYCODE_TV_INPUT_COMPOSITE_1 = 247
|
||||
KEYCODE_TV_INPUT_COMPOSITE_2 = 248
|
||||
KEYCODE_TV_INPUT_COMPONENT_1 = 249
|
||||
KEYCODE_TV_INPUT_COMPONENT_2 = 250
|
||||
KEYCODE_TV_INPUT_VGA_1 = 251
|
||||
KEYCODE_TV_AUDIO_DESCRIPTION = 252
|
||||
KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP = 253
|
||||
KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN = 254
|
||||
KEYCODE_TV_ZOOM_MODE = 255
|
||||
KEYCODE_TV_CONTENTS_MENU = 256
|
||||
KEYCODE_TV_MEDIA_CONTEXT_MENU = 257
|
||||
KEYCODE_TV_TIMER_PROGRAMMING = 258
|
||||
KEYCODE_HELP = 259
|
||||
KEYCODE_NAVIGATE_PREVIOUS = 260
|
||||
KEYCODE_NAVIGATE_NEXT = 261
|
||||
KEYCODE_NAVIGATE_IN = 262
|
||||
KEYCODE_NAVIGATE_OUT = 263
|
||||
KEYCODE_STEM_PRIMARY = 264
|
||||
KEYCODE_STEM_1 = 265
|
||||
KEYCODE_STEM_2 = 266
|
||||
KEYCODE_STEM_3 = 267
|
||||
KEYCODE_DPAD_UP_LEFT = 268
|
||||
KEYCODE_DPAD_DOWN_LEFT = 269
|
||||
KEYCODE_DPAD_UP_RIGHT = 270
|
||||
KEYCODE_DPAD_DOWN_RIGHT = 271
|
||||
KEYCODE_MEDIA_SKIP_FORWARD = 272
|
||||
KEYCODE_MEDIA_SKIP_BACKWARD = 273
|
||||
KEYCODE_MEDIA_STEP_FORWARD = 274
|
||||
KEYCODE_MEDIA_STEP_BACKWARD = 275
|
||||
KEYCODE_SOFT_SLEEP = 276
|
||||
KEYCODE_CUT = 277
|
||||
KEYCODE_COPY = 278
|
||||
KEYCODE_PASTE = 279
|
||||
KEYCODE_SYSTEM_NAVIGATION_UP = 280
|
||||
KEYCODE_SYSTEM_NAVIGATION_DOWN = 281
|
||||
KEYCODE_SYSTEM_NAVIGATION_LEFT = 282
|
||||
KEYCODE_SYSTEM_NAVIGATION_RIGHT = 283
|
||||
KEYCODE_KEYCODE_ALL_APPS = 284
|
||||
KEYCODE_KEYCODE_REFRESH = 285
|
||||
KEYCODE_KEYCODE_THUMBS_UP = 286
|
||||
KEYCODE_KEYCODE_THUMBS_DOWN = 287
|
||||
|
||||
# Event
|
||||
EVENT_INIT = "init"
|
||||
EVENT_FRAME = "frame"
|
||||
|
||||
# Type
|
||||
TYPE_INJECT_KEYCODE = 0
|
||||
TYPE_INJECT_TEXT = 1
|
||||
TYPE_INJECT_TOUCH_EVENT = 2
|
||||
TYPE_INJECT_SCROLL_EVENT = 3
|
||||
TYPE_BACK_OR_SCREEN_ON = 4
|
||||
TYPE_EXPAND_NOTIFICATION_PANEL = 5
|
||||
TYPE_EXPAND_SETTINGS_PANEL = 6
|
||||
TYPE_COLLAPSE_PANELS = 7
|
||||
TYPE_GET_CLIPBOARD = 8
|
||||
TYPE_SET_CLIPBOARD = 9
|
||||
TYPE_SET_SCREEN_POWER_MODE = 10
|
||||
TYPE_ROTATE_DEVICE = 11
|
||||
|
||||
COPY_KEY_NONE = 0
|
||||
COPY_KEY_COPY = 1
|
||||
COPY_KEY_CUT = 2
|
||||
|
||||
# Lock screen orientation
|
||||
LOCK_SCREEN_ORIENTATION_UNLOCKED = -1
|
||||
LOCK_SCREEN_ORIENTATION_INITIAL = -2
|
||||
LOCK_SCREEN_ORIENTATION_0 = 0
|
||||
LOCK_SCREEN_ORIENTATION_1 = 1
|
||||
LOCK_SCREEN_ORIENTATION_2 = 2
|
||||
LOCK_SCREEN_ORIENTATION_3 = 3
|
||||
|
||||
# Screen power mode
|
||||
POWER_MODE_OFF = 0
|
||||
POWER_MODE_NORMAL = 2
|
262
mower/utils/device/scrcpy/control.py
Normal file
262
mower/utils/device/scrcpy/control.py
Normal file
|
@ -0,0 +1,262 @@
|
|||
import functools
|
||||
import socket
|
||||
import struct
|
||||
from time import sleep
|
||||
|
||||
from . import const
|
||||
|
||||
|
||||
def inject(control_type: int):
|
||||
"""
|
||||
Inject control code, with this inject, we will be able to do unit test
|
||||
Args:
|
||||
control_type: event to send, TYPE_*
|
||||
"""
|
||||
|
||||
def wrapper(f):
|
||||
@functools.wraps(f)
|
||||
def inner(*args, **kwargs):
|
||||
package = struct.pack(">B", control_type) + f(*args, **kwargs)
|
||||
if args[0].parent.control_socket is not None:
|
||||
with args[0].parent.control_socket_lock:
|
||||
args[0].parent.control_socket.send(package)
|
||||
return package
|
||||
|
||||
return inner
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class ControlSender:
|
||||
def __init__(self, parent):
|
||||
self.parent = parent
|
||||
|
||||
@inject(const.TYPE_INJECT_KEYCODE)
|
||||
def keycode(
|
||||
self, keycode: int, action: int = const.ACTION_DOWN, repeat: int = 0
|
||||
) -> bytes:
|
||||
"""
|
||||
Send keycode to device
|
||||
Args:
|
||||
keycode: const.KEYCODE_*
|
||||
action: ACTION_DOWN | ACTION_UP
|
||||
repeat: repeat count
|
||||
"""
|
||||
return struct.pack(">Biii", action, keycode, repeat, 0)
|
||||
|
||||
@inject(const.TYPE_INJECT_TEXT)
|
||||
def text(self, text: str) -> bytes:
|
||||
"""
|
||||
Send text to device
|
||||
Args:
|
||||
text: text to send
|
||||
"""
|
||||
|
||||
buffer = text.encode("utf-8")
|
||||
return struct.pack(">i", len(buffer)) + buffer
|
||||
|
||||
@inject(const.TYPE_INJECT_TOUCH_EVENT)
|
||||
def touch(
|
||||
self, x: int, y: int, action: int = const.ACTION_DOWN, touch_id: int = -1
|
||||
) -> bytes:
|
||||
"""
|
||||
Touch screen
|
||||
Args:
|
||||
x: horizontal position
|
||||
y: vertical position
|
||||
action: ACTION_DOWN | ACTION_UP | ACTION_MOVE
|
||||
touch_id: Default using virtual id -1, you can specify it to emulate multi finger touch
|
||||
"""
|
||||
x, y = max(x, 0), max(y, 0)
|
||||
return struct.pack(
|
||||
">BqiiHHHi",
|
||||
action,
|
||||
touch_id,
|
||||
int(x),
|
||||
int(y),
|
||||
int(self.parent.resolution[0]),
|
||||
int(self.parent.resolution[1]),
|
||||
0xFFFF,
|
||||
1,
|
||||
)
|
||||
|
||||
@inject(const.TYPE_INJECT_SCROLL_EVENT)
|
||||
def scroll(self, x: int, y: int, h: int, v: int) -> bytes:
|
||||
"""
|
||||
Scroll screen
|
||||
Args:
|
||||
x: horizontal position
|
||||
y: vertical position
|
||||
h: horizontal movement
|
||||
v: vertical movement
|
||||
"""
|
||||
|
||||
x, y = max(x, 0), max(y, 0)
|
||||
return struct.pack(
|
||||
">iiHHii",
|
||||
int(x),
|
||||
int(y),
|
||||
int(self.parent.resolution[0]),
|
||||
int(self.parent.resolution[1]),
|
||||
int(h),
|
||||
int(v),
|
||||
)
|
||||
|
||||
@inject(const.TYPE_BACK_OR_SCREEN_ON)
|
||||
def back_or_turn_screen_on(self, action: int = const.ACTION_DOWN) -> bytes:
|
||||
"""
|
||||
If the screen is off, it is turned on only on ACTION_DOWN
|
||||
Args:
|
||||
action: ACTION_DOWN | ACTION_UP
|
||||
"""
|
||||
return struct.pack(">B", action)
|
||||
|
||||
@inject(const.TYPE_EXPAND_NOTIFICATION_PANEL)
|
||||
def expand_notification_panel(self) -> bytes:
|
||||
"""
|
||||
Expand notification panel
|
||||
"""
|
||||
return b""
|
||||
|
||||
@inject(const.TYPE_EXPAND_SETTINGS_PANEL)
|
||||
def expand_settings_panel(self) -> bytes:
|
||||
"""
|
||||
Expand settings panel
|
||||
"""
|
||||
return b""
|
||||
|
||||
@inject(const.TYPE_COLLAPSE_PANELS)
|
||||
def collapse_panels(self) -> bytes:
|
||||
"""
|
||||
Collapse all panels
|
||||
"""
|
||||
return b""
|
||||
|
||||
def get_clipboard(self, copy_key=const.COPY_KEY_NONE) -> str:
|
||||
"""
|
||||
Get clipboard
|
||||
"""
|
||||
# Since this function need socket response, we can't auto inject it any more
|
||||
s: socket.socket = self.parent.control_socket
|
||||
|
||||
with self.parent.control_socket_lock:
|
||||
# Flush socket
|
||||
s.setblocking(False)
|
||||
while True:
|
||||
try:
|
||||
s.recv(1024)
|
||||
except BlockingIOError:
|
||||
break
|
||||
s.setblocking(True)
|
||||
|
||||
# Read package
|
||||
package = struct.pack(">BB", const.TYPE_GET_CLIPBOARD, copy_key)
|
||||
s.send(package)
|
||||
(code,) = struct.unpack(">B", s.recv(1))
|
||||
assert code == 0
|
||||
(length,) = struct.unpack(">i", s.recv(4))
|
||||
|
||||
return s.recv(length).decode("utf-8")
|
||||
|
||||
@inject(const.TYPE_SET_CLIPBOARD)
|
||||
def set_clipboard(self, text: str, paste: bool = False) -> bytes:
|
||||
"""
|
||||
Set clipboard
|
||||
Args:
|
||||
text: the string you want to set
|
||||
paste: paste now
|
||||
"""
|
||||
buffer = text.encode("utf-8")
|
||||
return struct.pack(">?i", paste, len(buffer)) + buffer
|
||||
|
||||
@inject(const.TYPE_SET_SCREEN_POWER_MODE)
|
||||
def set_screen_power_mode(self, mode: int = const.POWER_MODE_NORMAL) -> bytes:
|
||||
"""
|
||||
Set screen power mode
|
||||
Args:
|
||||
mode: POWER_MODE_OFF | POWER_MODE_NORMAL
|
||||
"""
|
||||
return struct.pack(">b", mode)
|
||||
|
||||
@inject(const.TYPE_ROTATE_DEVICE)
|
||||
def rotate_device(self) -> bytes:
|
||||
"""
|
||||
Rotate device
|
||||
"""
|
||||
return b""
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
end_x: int,
|
||||
end_y: int,
|
||||
move_step_length: int = 5,
|
||||
move_steps_delay: float = 0.005,
|
||||
) -> None:
|
||||
"""
|
||||
Swipe on screen
|
||||
Args:
|
||||
start_x: start horizontal position
|
||||
start_y: start vertical position
|
||||
end_x: start horizontal position
|
||||
end_y: end vertical position
|
||||
move_step_length: length per step
|
||||
move_steps_delay: sleep seconds after each step
|
||||
:return:
|
||||
"""
|
||||
|
||||
self.touch(start_x, start_y, const.ACTION_DOWN)
|
||||
next_x = start_x
|
||||
next_y = start_y
|
||||
|
||||
if end_x > self.parent.resolution[0]:
|
||||
end_x = self.parent.resolution[0]
|
||||
|
||||
if end_y > self.parent.resolution[1]:
|
||||
end_y = self.parent.resolution[1]
|
||||
|
||||
decrease_x = True if start_x > end_x else False
|
||||
decrease_y = True if start_y > end_y else False
|
||||
while True:
|
||||
if decrease_x:
|
||||
next_x -= move_step_length
|
||||
if next_x < end_x:
|
||||
next_x = end_x
|
||||
else:
|
||||
next_x += move_step_length
|
||||
if next_x > end_x:
|
||||
next_x = end_x
|
||||
|
||||
if decrease_y:
|
||||
next_y -= move_step_length
|
||||
if next_y < end_y:
|
||||
next_y = end_y
|
||||
else:
|
||||
next_y += move_step_length
|
||||
if next_y > end_y:
|
||||
next_y = end_y
|
||||
|
||||
self.touch(next_x, next_y, const.ACTION_MOVE)
|
||||
|
||||
if next_x == end_x and next_y == end_y:
|
||||
self.touch(next_x, next_y, const.ACTION_UP)
|
||||
break
|
||||
sleep(move_steps_delay)
|
||||
|
||||
def tap(self, x, y, hold_time: float = 0.07) -> None:
|
||||
"""
|
||||
Tap on screen
|
||||
Args:
|
||||
x: horizontal position
|
||||
y: vertical position
|
||||
hold_time: hold time
|
||||
"""
|
||||
self.touch(x, y, const.ACTION_DOWN)
|
||||
sleep(hold_time)
|
||||
self.touch(x, y, const.ACTION_UP)
|
||||
|
||||
def send_keyevent(self, key_code, hold_time: float = 0.07):
|
||||
self.keycode(key_code, const.ACTION_DOWN)
|
||||
sleep(hold_time)
|
||||
self.keycode(key_code, const.ACTION_UP)
|
237
mower/utils/device/scrcpy/core.py
Normal file
237
mower/utils/device/scrcpy/core.py
Normal file
|
@ -0,0 +1,237 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from mower import __rootdir__
|
||||
from mower.utils.device.adb_client.core import Client as ADBClient
|
||||
from mower.utils.device.adb_client.socket import Socket
|
||||
from mower.utils.device.scrcpy import const
|
||||
from mower.utils.device.scrcpy.control import ControlSender
|
||||
from mower.utils.log import logger
|
||||
|
||||
SCR_PATH = "/data/local/tmp/minitouch"
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(
|
||||
self,
|
||||
client: ADBClient,
|
||||
max_width: int = 0,
|
||||
bitrate: int = 8000000,
|
||||
max_fps: int = 0,
|
||||
flip: bool = False,
|
||||
block_frame: bool = False,
|
||||
stay_awake: bool = False,
|
||||
lock_screen_orientation: int = const.LOCK_SCREEN_ORIENTATION_UNLOCKED,
|
||||
displayid: Optional[int] = None,
|
||||
connection_timeout: int = 3000,
|
||||
):
|
||||
"""
|
||||
Create a scrcpy client, this client won't be started until you call the start function
|
||||
Args:
|
||||
client: ADB client
|
||||
max_width: frame width that will be broadcast from android server
|
||||
bitrate: bitrate
|
||||
max_fps: maximum fps, 0 means not limited (supported after android 10)
|
||||
flip: flip the video
|
||||
block_frame: only return nonempty frames, may block cv2 render thread
|
||||
stay_awake: keep Android device awake
|
||||
lock_screen_orientation: lock screen orientation, LOCK_SCREEN_ORIENTATION_*
|
||||
connection_timeout: timeout for connection, unit is ms
|
||||
"""
|
||||
|
||||
# User accessible
|
||||
self.client = client
|
||||
self.last_frame: Optional[np.ndarray] = None
|
||||
self.resolution: Optional[Tuple[int, int]] = None
|
||||
self.device_name: Optional[str] = None
|
||||
self.control = ControlSender(self)
|
||||
|
||||
# Params
|
||||
self.flip = flip
|
||||
self.max_width = max_width
|
||||
self.bitrate = bitrate
|
||||
self.max_fps = max_fps
|
||||
self.block_frame = block_frame
|
||||
self.stay_awake = stay_awake
|
||||
self.lock_screen_orientation = lock_screen_orientation
|
||||
self.connection_timeout = connection_timeout
|
||||
self.displayid = displayid
|
||||
|
||||
# Need to destroy
|
||||
self.__server_stream: Optional[Socket] = None
|
||||
self.__video_socket: Optional[Socket] = None
|
||||
self.control_socket: Optional[Socket] = None
|
||||
self.control_socket_lock = threading.Lock()
|
||||
|
||||
self.start()
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.stop()
|
||||
|
||||
def __start_server(self) -> None:
|
||||
"""
|
||||
Start server and get the connection
|
||||
"""
|
||||
cmdline = f"CLASSPATH={SCR_PATH} app_process /data/local/tmp com.genymobile.scrcpy.Server 1.21 log_level=verbose control=true tunnel_forward=true"
|
||||
if self.displayid is not None:
|
||||
cmdline += f" display_id={self.displayid}"
|
||||
self.__server_stream: Socket = self.client.stream_shell(cmdline)
|
||||
# Wait for server to start
|
||||
response = self.__server_stream.recv(100)
|
||||
logger.debug(response)
|
||||
if b"[server]" not in response:
|
||||
raise ConnectionError(
|
||||
"Failed to start scrcpy-server: " + response.decode("utf-8", "ignore")
|
||||
)
|
||||
|
||||
def __deploy_server(self) -> None:
|
||||
"""
|
||||
Deploy server to android device
|
||||
"""
|
||||
server_file_path = (
|
||||
__rootdir__
|
||||
/ "vendor"
|
||||
/ "scrcpy-server-novideo"
|
||||
/ "scrcpy-server-novideo.jar"
|
||||
)
|
||||
server_buf = server_file_path.read_bytes()
|
||||
self.client.push(SCR_PATH, server_buf)
|
||||
self.__start_server()
|
||||
|
||||
def __init_server_connection(self) -> None:
|
||||
"""
|
||||
Connect to android server, there will be two sockets, video and control socket.
|
||||
This method will set: video_socket, control_socket, resolution variables
|
||||
"""
|
||||
try:
|
||||
self.__video_socket = self.client.stream("localabstract:scrcpy")
|
||||
except socket.timeout:
|
||||
raise ConnectionError("Failed to connect scrcpy-server")
|
||||
|
||||
dummy_byte = self.__video_socket.recv(1)
|
||||
if not len(dummy_byte) or dummy_byte != b"\x00":
|
||||
raise ConnectionError("Did not receive Dummy Byte!")
|
||||
|
||||
try:
|
||||
self.control_socket = self.client.stream("localabstract:scrcpy")
|
||||
except socket.timeout:
|
||||
raise ConnectionError("Failed to connect scrcpy-server")
|
||||
|
||||
self.device_name = self.__video_socket.recv(64).decode("utf-8")
|
||||
self.device_name = self.device_name.rstrip("\x00")
|
||||
if not len(self.device_name):
|
||||
raise ConnectionError("Did not receive Device Name!")
|
||||
|
||||
res = self.__video_socket.recv(4)
|
||||
self.resolution = struct.unpack(">HH", res)
|
||||
# self.__video_socket.setblocking(False)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Start listening video stream
|
||||
"""
|
||||
try_count = 0
|
||||
while try_count < 3:
|
||||
try:
|
||||
self.__deploy_server()
|
||||
time.sleep(0.5)
|
||||
self.__init_server_connection()
|
||||
break
|
||||
except ConnectionError as e:
|
||||
logger.exception(f"Failed to connect scrcpy-server: {e}")
|
||||
self.stop()
|
||||
logger.warning("Try again in 10 seconds...")
|
||||
time.sleep(10)
|
||||
try_count += 1
|
||||
else:
|
||||
raise RuntimeError("Failed to connect scrcpy-server.")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
Stop listening (both threaded and blocked)
|
||||
"""
|
||||
if self.__server_stream is not None:
|
||||
self.__server_stream.close()
|
||||
self.__server_stream = None
|
||||
if self.control_socket is not None:
|
||||
self.control_socket.close()
|
||||
self.control_socket = None
|
||||
if self.__video_socket is not None:
|
||||
self.__video_socket.close()
|
||||
self.__video_socket = None
|
||||
|
||||
def check_adb_alive(self) -> bool:
|
||||
"""check if adb server alive"""
|
||||
return self.client.check_server_alive()
|
||||
|
||||
def stable(f):
|
||||
@functools.wraps(f)
|
||||
def inner(self: Client, *args, **kwargs):
|
||||
try_count = 0
|
||||
while try_count < 3:
|
||||
try:
|
||||
f(self, *args, **kwargs)
|
||||
break
|
||||
except (ConnectionResetError, BrokenPipeError):
|
||||
self.stop()
|
||||
time.sleep(1)
|
||||
self.check_adb_alive()
|
||||
self.start()
|
||||
try_count += 1
|
||||
else:
|
||||
raise RuntimeError("Failed to start scrcpy-server.")
|
||||
|
||||
return inner
|
||||
|
||||
@stable
|
||||
def tap(self, x: int, y: int) -> None:
|
||||
self.control.tap(x, y)
|
||||
|
||||
@stable
|
||||
def swipe(
|
||||
self,
|
||||
x0,
|
||||
y0,
|
||||
x1,
|
||||
y1,
|
||||
move_duraion: float = 1,
|
||||
hold_before_release: float = 0,
|
||||
fall: bool = True,
|
||||
lift: bool = True,
|
||||
):
|
||||
frame_time = 1 / 60
|
||||
|
||||
start_time = time.perf_counter()
|
||||
end_time = start_time + move_duraion
|
||||
fall and self.control.touch(x0, y0, const.ACTION_DOWN)
|
||||
t1 = time.perf_counter()
|
||||
step_time = t1 - start_time
|
||||
if step_time < frame_time:
|
||||
time.sleep(frame_time - step_time)
|
||||
while True:
|
||||
t0 = time.perf_counter()
|
||||
if t0 > end_time:
|
||||
break
|
||||
time_progress = (t0 - start_time) / move_duraion
|
||||
path_progress = time_progress
|
||||
self.control.touch(
|
||||
int(x0 + (x1 - x0) * path_progress),
|
||||
int(y0 + (y1 - y0) * path_progress),
|
||||
const.ACTION_MOVE,
|
||||
)
|
||||
t1 = time.perf_counter()
|
||||
step_time = t1 - t0
|
||||
if step_time < frame_time:
|
||||
time.sleep(frame_time - step_time)
|
||||
self.control.touch(x1, y1, const.ACTION_MOVE)
|
||||
if hold_before_release > 0:
|
||||
time.sleep(hold_before_release)
|
||||
lift and self.control.touch(x1, y1, const.ACTION_UP)
|
117
mower/utils/digit_reader.py
Normal file
117
mower/utils/digit_reader.py
Normal file
|
@ -0,0 +1,117 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .image import loadres
|
||||
|
||||
|
||||
class DigitReader:
|
||||
def __init__(self, template_dir=None):
|
||||
if not template_dir:
|
||||
template_dir = Path(os.path.dirname(os.path.abspath(__file__))) / Path(
|
||||
"templates"
|
||||
)
|
||||
if not isinstance(template_dir, Path):
|
||||
template_dir = Path(template_dir)
|
||||
self.time_template = []
|
||||
self.drone_template = []
|
||||
for i in range(10):
|
||||
self.time_template.append(loadres(f"orders_time/{i}", True))
|
||||
self.drone_template.append(loadres(f"drone_count/{i}", True))
|
||||
|
||||
def get_drone(self, img_grey, h=1080, w=1920):
|
||||
drone_part = img_grey[
|
||||
h * 32 // 1080 : h * 76 // 1080, w * 1144 // 1920 : w * 1225 // 1920
|
||||
]
|
||||
drone_part = cv2.resize(drone_part, (81, 44), interpolation=cv2.INTER_AREA)
|
||||
result = {}
|
||||
for j in range(10):
|
||||
res = cv2.matchTemplate(
|
||||
drone_part,
|
||||
self.drone_template[j],
|
||||
cv2.TM_CCORR_NORMED,
|
||||
)
|
||||
threshold = 0.95
|
||||
loc = np.where(res >= threshold)
|
||||
for i in range(len(loc[0])):
|
||||
offset = loc[1][i]
|
||||
accept = True
|
||||
for o in result:
|
||||
if abs(o - offset) < 5:
|
||||
accept = False
|
||||
break
|
||||
if accept:
|
||||
result[loc[1][i]] = j
|
||||
ch = [str(result[k]) for k in sorted(result)]
|
||||
return int("".join(ch))
|
||||
|
||||
def get_time(self, img_grey, h, w):
|
||||
digit_part = img_grey[h * 510 // 1080 : h * 543 // 1080, w * 499 // 1920 : w]
|
||||
digit_part = cv2.resize(digit_part, (1421, 33), interpolation=cv2.INTER_AREA)
|
||||
result = {}
|
||||
for j in range(10):
|
||||
res = cv2.matchTemplate(
|
||||
digit_part,
|
||||
self.time_template[j],
|
||||
cv2.TM_CCOEFF_NORMED,
|
||||
)
|
||||
threshold = 0.85
|
||||
loc = np.where(res >= threshold)
|
||||
for i in range(len(loc[0])):
|
||||
x = loc[1][i]
|
||||
accept = True
|
||||
for o in result:
|
||||
if abs(o - x) < 5:
|
||||
accept = False
|
||||
break
|
||||
if accept:
|
||||
if len(result) == 0:
|
||||
digit_part = digit_part[:, loc[1][i] - 5 : loc[1][i] + 116]
|
||||
offset = loc[1][0] - 5
|
||||
for m in range(len(loc[1])):
|
||||
loc[1][m] -= offset
|
||||
result[loc[1][i]] = j
|
||||
ch = [str(result[k]) for k in sorted(result)]
|
||||
return f"{ch[0]}{ch[1]}:{ch[2]}{ch[3]}:{ch[4]}{ch[5]}"
|
||||
|
||||
def 识别制造加速总剩余时间(self, img_grey, h, w):
|
||||
时间部分 = img_grey[
|
||||
h * 665 // 1080 : h * 709 // 1080, w * 750 // 1920 : w * 960 // 1920
|
||||
]
|
||||
时间部分 = cv2.resize(
|
||||
时间部分, (210 * 58 // 71, 44 * 58 // 71), interpolation=cv2.INTER_AREA
|
||||
)
|
||||
result = {}
|
||||
for j in range(10):
|
||||
res = cv2.matchTemplate(
|
||||
时间部分,
|
||||
self.drone_template[j],
|
||||
cv2.TM_CCOEFF_NORMED,
|
||||
)
|
||||
threshold = 0.85
|
||||
loc = np.where(res >= threshold)
|
||||
for i in range(len(loc[0])):
|
||||
offset = loc[1][i]
|
||||
accept = True
|
||||
for o in result:
|
||||
if abs(o - offset) < 5:
|
||||
accept = False
|
||||
break
|
||||
if accept:
|
||||
result[loc[1][i]] = j
|
||||
ch = [str(result[k]) for k in sorted(result)]
|
||||
print(ch)
|
||||
if len(ch) == 6:
|
||||
return (
|
||||
int(f"{ch[0]}{ch[1]}"),
|
||||
int(f"{ch[2]}{ch[3]}"),
|
||||
int(f"{ch[4]}{ch[5]}"),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
int(f"{ch[0]}{ch[1]}{ch[2]}"),
|
||||
int(f"{ch[3]}{ch[4]}"),
|
||||
int(f"{ch[5]}{ch[6]}"),
|
||||
)
|
102
mower/utils/email.py
Normal file
102
mower/utils/email.py
Normal file
|
@ -0,0 +1,102 @@
|
|||
import smtplib
|
||||
from email.mime.image import MIMEImage
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from threading import Thread
|
||||
from time import sleep
|
||||
from typing import Literal, Optional
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
from mower.utils import config
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.image import img2bytes
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.path import get_path
|
||||
|
||||
template_dir = get_path("@install/mower/templates")
|
||||
env = Environment(loader=FileSystemLoader(template_dir), autoescape=select_autoescape())
|
||||
|
||||
task_template = env.get_template("task.html")
|
||||
drop_template = env.get_template("drop.html")
|
||||
recruit_template = env.get_template("recruit_template.html")
|
||||
recruit_rarity = env.get_template("recruit_rarity.html")
|
||||
report_template = env.get_template("report_template.html")
|
||||
|
||||
|
||||
class Email:
|
||||
def __init__(self, body, subject, attach_image):
|
||||
conf = config.conf
|
||||
msg = MIMEMultipart()
|
||||
msg.attach(MIMEText(body, "html"))
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = conf.account
|
||||
msg["To"] = ", ".join(conf.recipient)
|
||||
|
||||
if attach_image is not None:
|
||||
attachment = img2bytes(attach_image)
|
||||
image_content = MIMEImage(attachment.tobytes())
|
||||
image_content.add_header(
|
||||
"Content-Disposition", "attachment", filename="image.jpg"
|
||||
)
|
||||
msg.attach(image_content)
|
||||
self.msg = msg
|
||||
|
||||
if conf.custom_smtp_server.enable:
|
||||
self.smtp_server = conf.custom_smtp_server.server
|
||||
self.port = conf.custom_smtp_server.ssl_port
|
||||
self.encryption = conf.custom_smtp_server.encryption
|
||||
else:
|
||||
self.smtp_server = "smtp.qq.com"
|
||||
self.port = 465
|
||||
self.encryption = "tls"
|
||||
|
||||
def send(self):
|
||||
if self.encryption == "starttls":
|
||||
s = smtplib.SMTP(self.smtp_server, self.port, timeout=10)
|
||||
s.starttls()
|
||||
else:
|
||||
s = smtplib.SMTP_SSL(self.smtp_server, self.port, timeout=10)
|
||||
conf = config.conf
|
||||
s.login(conf.account, conf.pass_code)
|
||||
recipient = conf.recipient or [conf.account]
|
||||
s.send_message(self.msg, conf.account, recipient)
|
||||
s.quit()
|
||||
|
||||
|
||||
def send_message(
|
||||
body="",
|
||||
subject="",
|
||||
level: Literal["INFO", "WARNING", "ERROR"] = "INFO",
|
||||
attach_image: Optional[tp.Image] = None,
|
||||
):
|
||||
"""异步发送邮件
|
||||
|
||||
Args:
|
||||
body: 邮件内容
|
||||
subject: 邮件标题
|
||||
level: 通知等级
|
||||
attach_image: 图片附件
|
||||
"""
|
||||
conf = config.conf
|
||||
if not conf.mail_enable:
|
||||
return
|
||||
if conf.notification_level == "WARNING" and level == "INFO":
|
||||
return
|
||||
if conf.notification_level == "ERROR" and level != "ERROR":
|
||||
return
|
||||
if subject == "":
|
||||
subject = body.split("\n")[0].strip()
|
||||
subject = conf.mail_subject + subject
|
||||
email = Email(body, subject, attach_image)
|
||||
|
||||
def send_message_sync(email):
|
||||
for i in range(3):
|
||||
try:
|
||||
email.send()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.exception("邮件发送失败:" + str(e))
|
||||
sleep(2**i)
|
||||
|
||||
Thread(target=send_message_sync, args=(email,)).start()
|
161
mower/utils/git_rev.py
Normal file
161
mower/utils/git_rev.py
Normal file
|
@ -0,0 +1,161 @@
|
|||
"""
|
||||
https://gist.github.com/pkienzle/5e13ec07077d32985fa48ebe43486832
|
||||
|
||||
Get commit id from the git repo.
|
||||
|
||||
Drop the file rev.py into directory PACKAGE_PATH of your application. From
|
||||
within that package you can then do::
|
||||
|
||||
from . import rev
|
||||
|
||||
rev.print_revision() # print the repo version
|
||||
commit = rev.revision_info() # return commit id
|
||||
|
||||
On "pip install" the repo root directory will not be available. In this
|
||||
case the code looks for PACKAGE_NAME/git_revision, which you need to
|
||||
install into site-pacakges along with your other sources.
|
||||
|
||||
The simplest way to create PACKAGE_NAME/git_revision is to run rev
|
||||
from setup.py::
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Create the resource file git_revision.
|
||||
if os.system(f'"{sys.executable}" PACKAGE_NAME/rev.py') != 0:
|
||||
print("setup.py failed to build PACKAGE_NAME/git_revision", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
...
|
||||
|
||||
# Include git revision in the package data, eitherj by adding
|
||||
# "include PACKAGE_NAME/git_revision" to MANIFEST.in, or by
|
||||
# adding the following to setup.py:
|
||||
#package_data = {"PACKAGE_NAME": ["git_revision"]}
|
||||
setup(
|
||||
...
|
||||
#package_data=package_data,
|
||||
include_package_data=True,
|
||||
...
|
||||
)
|
||||
|
||||
Add the following to .gitignore, substituting your package name::
|
||||
|
||||
/PACKAGE_NAME/git_revision
|
||||
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from warnings import warn
|
||||
|
||||
|
||||
def repo_path():
|
||||
"""Return the root of the project git repo or None if not in a repo."""
|
||||
base = Path(__file__).absolute()
|
||||
for path in base.parents:
|
||||
if (path / ".git").exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def print_revision():
|
||||
"""Print the git revision"""
|
||||
revision = revision_info()
|
||||
print("git revision", revision)
|
||||
|
||||
|
||||
def store_revision():
|
||||
"""
|
||||
Call from setup.py to save the git revision to the distribution.
|
||||
|
||||
See :mod:`rev` for details.
|
||||
"""
|
||||
commit = git_rev(repo_path())
|
||||
path = Path(__file__).absolute().parent / RESOURCE_NAME
|
||||
with path.open("w") as fd:
|
||||
fd.write(commit + "\n")
|
||||
|
||||
|
||||
RESOURCE_NAME = "git_revision"
|
||||
_REVISION_INFO = None # cached value of git revision
|
||||
|
||||
|
||||
def revision_info():
|
||||
"""
|
||||
Get the git hash and mtime of the repository, or the installed files.
|
||||
"""
|
||||
# TODO: test with "pip install -e ." for developer mode
|
||||
global _REVISION_INFO
|
||||
|
||||
if _REVISION_INFO is None:
|
||||
_REVISION_INFO = git_rev(repo_path())
|
||||
|
||||
if _REVISION_INFO is None:
|
||||
try:
|
||||
from importlib import resources
|
||||
except ImportError: # CRUFT: pre-3.7 requires importlib_resources
|
||||
import importlib_resources as resources
|
||||
try:
|
||||
# Parse __name__ as "package.lib.rev" into "package.lib"
|
||||
package = __name__.rsplit(".", 1)[0]
|
||||
revdata = resources.read_text(package, RESOURCE_NAME)
|
||||
commit = revdata.strip()
|
||||
_REVISION_INFO = commit
|
||||
except Exception:
|
||||
_REVISION_INFO = "unknown"
|
||||
|
||||
return _REVISION_INFO
|
||||
|
||||
|
||||
def git_rev(repo):
|
||||
"""
|
||||
Get the git revision for the repo in the path *repo*.
|
||||
|
||||
Returns the commit id of the current head.
|
||||
|
||||
Note: this function parses the files in the git repository directory
|
||||
without using the git application. It may break if the structure of
|
||||
the git repository changes. It only reads files, so it should not do
|
||||
any damage to the repository in the process.
|
||||
"""
|
||||
# Based on stackoverflow am9417
|
||||
# https://stackoverflow.com/questions/14989858/get-the-current-git-hash-in-a-python-script/59950703#59950703
|
||||
if repo is None:
|
||||
return None
|
||||
|
||||
git_root = Path(repo) / ".git"
|
||||
git_head = git_root / "HEAD"
|
||||
if not git_head.exists():
|
||||
return None
|
||||
|
||||
# Read .git/HEAD file
|
||||
with git_head.open("r") as fd:
|
||||
head_ref = fd.read()
|
||||
|
||||
# Find head file .git/HEAD (e.g. ref: ref/heads/master => .git/ref/heads/master)
|
||||
if not head_ref.startswith("ref: "):
|
||||
return head_ref
|
||||
head_ref = head_ref[5:].strip()
|
||||
|
||||
# Read commit id from head file
|
||||
head_path = git_root.joinpath(*head_ref.split("/"))
|
||||
if not head_path.exists():
|
||||
warn(f"path {head_path} referenced from {git_head} does not exist")
|
||||
return None
|
||||
|
||||
with head_path.open("r") as fd:
|
||||
commit = fd.read().strip()
|
||||
|
||||
return commit
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
When run as a python script create git_revision in the current directory.
|
||||
"""
|
||||
print_revision()
|
||||
store_revision()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
31
mower/utils/graph/__init__.py
Normal file
31
mower/utils/graph/__init__.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
from . import (
|
||||
extra,
|
||||
friend,
|
||||
index,
|
||||
mission,
|
||||
navbar,
|
||||
operation,
|
||||
recruit,
|
||||
riic,
|
||||
shop,
|
||||
sss,
|
||||
terminal,
|
||||
)
|
||||
from .utils import DG, SceneGraphSolver, edge
|
||||
|
||||
__all__ = [
|
||||
"SceneGraphSolver",
|
||||
"DG",
|
||||
"edge",
|
||||
"extra",
|
||||
"friend",
|
||||
"index",
|
||||
"mission",
|
||||
"navbar",
|
||||
"operation",
|
||||
"recruit",
|
||||
"riic",
|
||||
"shop",
|
||||
"sss",
|
||||
"terminal",
|
||||
]
|
46
mower/utils/graph/extra.py
Normal file
46
mower/utils/graph/extra.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 其它场景
|
||||
|
||||
|
||||
@edge(Scene.UNDEFINED, Scene.INDEX)
|
||||
def get_scene(solver: BaseSolver):
|
||||
solver.scene()
|
||||
|
||||
|
||||
@edge(Scene.LOGIN_START, Scene.LOGIN_QUICKLY)
|
||||
def login_start(solver: BaseSolver):
|
||||
solver.tap((665, 741))
|
||||
|
||||
|
||||
@edge(Scene.CONFIRM, Scene.LOGIN_START)
|
||||
def confirm(solver: BaseSolver):
|
||||
solver.tap_element("confirm")
|
||||
|
||||
|
||||
@edge(Scene.NOTICE, Scene.INDEX)
|
||||
def close_notice(solver: BaseSolver):
|
||||
solver.tap_element("notice")
|
||||
|
||||
|
||||
@edge(Scene.NETWORK_CHECK, Scene.LOGIN_START)
|
||||
def network_check_cancel(solver: BaseSolver):
|
||||
solver.tap_element("confirm")
|
||||
|
||||
|
||||
@edge(Scene.MOON_FESTIVAL, Scene.SIGN_IN_DAILY)
|
||||
def moon_festival(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.STORY, Scene.STORY_SKIP)
|
||||
def skip_story(solver: BaseSolver):
|
||||
solver.tap((1655, 781))
|
||||
|
||||
|
||||
@edge(Scene.STORY_SKIP, Scene.OPERATOR_BEFORE)
|
||||
def skip_story_confirm(solver: BaseSolver):
|
||||
solver.tap_element("story_skip_confirm_dialog", x_rate=0.94)
|
26
mower/utils/graph/friend.py
Normal file
26
mower/utils/graph/friend.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 好友
|
||||
|
||||
|
||||
@edge(Scene.BUSINESS_CARD, Scene.FRIEND_LIST)
|
||||
def friend_list(solver: BaseSolver):
|
||||
solver.tap((194, 333))
|
||||
|
||||
|
||||
@edge(Scene.FRIEND_LIST, Scene.BUSINESS_CARD)
|
||||
def business_card(solver: BaseSolver):
|
||||
solver.tap((188, 198))
|
||||
|
||||
|
||||
@edge(Scene.FRIEND_VISITING, Scene.BACK_TO_FRIEND_LIST)
|
||||
def friend_visiting_back(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.BACK_TO_FRIEND_LIST, Scene.BUSINESS_CARD)
|
||||
def back_to_friend_confirm(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=1)
|
129
mower/utils/graph/index.py
Normal file
129
mower/utils/graph/index.py
Normal file
|
@ -0,0 +1,129 @@
|
|||
from mower.utils import config
|
||||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 首页
|
||||
|
||||
|
||||
@edge(Scene.INFRA_MAIN, Scene.INDEX)
|
||||
@edge(Scene.MISSION_DAILY, Scene.INDEX)
|
||||
@edge(Scene.MISSION_WEEKLY, Scene.INDEX)
|
||||
@edge(Scene.MISSION_TRAINEE, Scene.INDEX)
|
||||
@edge(Scene.BUSINESS_CARD, Scene.INDEX)
|
||||
@edge(Scene.FRIEND_LIST, Scene.INDEX)
|
||||
@edge(Scene.RECRUIT_MAIN, Scene.INDEX)
|
||||
@edge(Scene.SHOP_OTHERS, Scene.INDEX)
|
||||
@edge(Scene.SHOP_CREDIT, Scene.INDEX)
|
||||
@edge(Scene.SHOP_TOKEN, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_MAIN, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_MAIN_THEME, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_EPISODE, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_BIOGRAPHY, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_COLLECTION, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_REGULAR, Scene.INDEX)
|
||||
@edge(Scene.TERMINAL_LONGTERM, Scene.INDEX)
|
||||
@edge(Scene.DEPOT, Scene.INDEX)
|
||||
@edge(Scene.HEADHUNTING, Scene.INDEX)
|
||||
@edge(Scene.MAIL, Scene.INDEX)
|
||||
@edge(Scene.SIGN_IN_DAILY, Scene.INDEX)
|
||||
@edge(Scene.INDEX_ORIGINITE, Scene.INDEX)
|
||||
@edge(Scene.INDEX_SANITY, Scene.INDEX)
|
||||
def back_to_index(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.LEAVE_INFRASTRUCTURE, Scene.INDEX)
|
||||
def leave_infrastructure(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=1)
|
||||
|
||||
|
||||
@edge(Scene.DOWNLOAD_VOICE_RESOURCES, Scene.INDEX)
|
||||
def dont_download_voice(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=0)
|
||||
|
||||
|
||||
@edge(Scene.LOGIN_QUICKLY, Scene.INDEX)
|
||||
def login_quickly(solver: BaseSolver):
|
||||
solver.tap_element("login_awake")
|
||||
|
||||
|
||||
@edge(Scene.LOGIN_CAPTCHA, Scene.INDEX)
|
||||
def login_captcha(solver: BaseSolver):
|
||||
solver.solve_captcha()
|
||||
solver.sleep(5)
|
||||
|
||||
|
||||
@edge(Scene.LOGIN_BILIBILI, Scene.INDEX)
|
||||
def login_bilibili(solver: BaseSolver):
|
||||
solver.tap((1000, 600))
|
||||
|
||||
|
||||
@edge(Scene.EXIT_GAME, Scene.INDEX)
|
||||
def exit_cancel(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=0)
|
||||
|
||||
|
||||
@edge(Scene.MATERIEL, Scene.INDEX)
|
||||
def materiel(solver: BaseSolver):
|
||||
solver.tap((960, 960))
|
||||
|
||||
|
||||
@edge(Scene.ANNOUNCEMENT, Scene.INDEX)
|
||||
def announcement(solver: BaseSolver):
|
||||
solver.tap(config.recog.check_announcement())
|
||||
|
||||
|
||||
@edge(Scene.AGREEMENT_UPDATE, Scene.INDEX)
|
||||
def agreement(solver: BaseSolver):
|
||||
if pos := solver.find("read_and_agree"):
|
||||
solver.tap(pos)
|
||||
else:
|
||||
solver.tap((791, 728))
|
||||
solver.tap((959, 828))
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.INFRA_MAIN)
|
||||
def index_to_infra(solver: BaseSolver):
|
||||
solver.tap_index_element("infrastructure")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.BUSINESS_CARD)
|
||||
def index_to_friend(solver: BaseSolver):
|
||||
solver.tap_index_element("friend")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.MISSION_DAILY)
|
||||
def index_to_mission(solver: BaseSolver):
|
||||
solver.tap_index_element("mission")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.RECRUIT_MAIN)
|
||||
def index_to_recruit(solver: BaseSolver):
|
||||
solver.tap_index_element("recruit")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.SHOP_OTHERS)
|
||||
def index_to_shop(solver: BaseSolver):
|
||||
solver.tap_index_element("shop")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.TERMINAL_MAIN)
|
||||
def index_to_terminal(solver: BaseSolver):
|
||||
solver.tap_index_element("terminal")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.DEPOT)
|
||||
def index_to_depot(solver: BaseSolver):
|
||||
solver.tap_index_element("warehouse")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.MAIL)
|
||||
def index_to_mail(solver: BaseSolver):
|
||||
solver.tap_index_element("mail")
|
||||
|
||||
|
||||
@edge(Scene.INDEX, Scene.HEADHUNTING)
|
||||
def index_to_headhunting(solver: BaseSolver):
|
||||
solver.tap_index_element("headhunting")
|
17
mower/utils/graph/mission.py
Normal file
17
mower/utils/graph/mission.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 任务
|
||||
|
||||
|
||||
@edge(Scene.MISSION_DAILY, Scene.MISSION_WEEKLY)
|
||||
@edge(Scene.MISSION_TRAINEE, Scene.MISSION_WEEKLY)
|
||||
def mission_to_weekly(solver: BaseSolver):
|
||||
solver.tap_element("mission_weekly")
|
||||
|
||||
|
||||
@edge(Scene.MISSION_TRAINEE, Scene.MISSION_DAILY)
|
||||
def mission_trainee_to_daily(solver: BaseSolver):
|
||||
solver.tap_element("mission_daily")
|
90
mower/utils/graph/navbar.py
Normal file
90
mower/utils/graph/navbar.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 导航栏
|
||||
|
||||
|
||||
@edge(Scene.INFRA_MAIN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.RECRUIT_MAIN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.RECRUIT_TAGS, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.MISSION_DAILY, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.MISSION_WEEKLY, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.MISSION_TRAINEE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.BUSINESS_CARD, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.FRIEND_LIST, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.SHOP_OTHERS, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.SHOP_CREDIT, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.SHOP_TOKEN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_MAIN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_MAIN_THEME, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_EPISODE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_BIOGRAPHY, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_COLLECTION, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_REGULAR, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_LONGTERM, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.TERMINAL_PERIODIC, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.OPERATOR_CHOOSE_LEVEL, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.OPERATOR_BEFORE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.OPERATOR_SELECT, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.OPERATOR_SUPPORT, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.INFRA_TODOLIST, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.INFRA_CONFIDENTIAL, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.INFRA_ARRANGE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.INFRA_DETAILS, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.CTRLCENTER_ASSISTANT, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.CLUE_DAILY, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.CLUE_RECEIVE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.CLUE_PLACE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.ORDER_LIST, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.FACTORY_ROOMS, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.OPERATOR_ELIMINATE, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.DEPOT, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.FRIEND_VISITING, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.HEADHUNTING, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.UNKNOWN_WITH_NAVBAR, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.SSS_MAIN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.SSS_START, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.ACTIVITY_MAIN, Scene.NAVIGATION_BAR)
|
||||
@edge(Scene.ACTIVITY_CHOOSE_LEVEL, Scene.NAVIGATION_BAR)
|
||||
def index_nav(solver: BaseSolver):
|
||||
solver.tap_element("nav_button")
|
||||
|
||||
|
||||
# 不加从导航栏到基建首页的边,防止在基建内循环
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.MISSION_DAILY)
|
||||
def nav_mission(solver: BaseSolver):
|
||||
solver.tap_nav_element("mission")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.INDEX)
|
||||
def nav_index(solver: BaseSolver):
|
||||
solver.tap_nav_element("index")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.TERMINAL_MAIN)
|
||||
def nav_terminal(solver: BaseSolver):
|
||||
solver.tap_nav_element("terminal")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.RECRUIT_MAIN)
|
||||
def nav_recruit(solver: BaseSolver):
|
||||
solver.tap_nav_element("recruit")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.SHOP_OTHERS)
|
||||
def nav_shop(solver: BaseSolver):
|
||||
solver.tap_nav_element("shop")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.HEADHUNTING)
|
||||
def nav_headhunting(solver: BaseSolver):
|
||||
solver.tap_nav_element("headhunting")
|
||||
|
||||
|
||||
@edge(Scene.NAVIGATION_BAR, Scene.BUSINESS_CARD)
|
||||
def nav_friend(solver: BaseSolver):
|
||||
solver.tap_nav_element("friend")
|
38
mower/utils/graph/operation.py
Normal file
38
mower/utils/graph/operation.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 作战
|
||||
|
||||
|
||||
@edge(Scene.OPERATOR_RECOVER_POTION, Scene.OPERATOR_BEFORE)
|
||||
@edge(Scene.OPERATOR_RECOVER_ORIGINITE, Scene.OPERATOR_BEFORE)
|
||||
@edge(Scene.OPERATOR_BEFORE, Scene.OPERATOR_CHOOSE_LEVEL)
|
||||
@edge(Scene.OPERATOR_CHOOSE_LEVEL, Scene.TERMINAL_MAIN_THEME)
|
||||
@edge(Scene.OPERATOR_CHOOSE_LEVEL, Scene.TERMINAL_BIOGRAPHY)
|
||||
@edge(Scene.OPERATOR_CHOOSE_LEVEL, Scene.TERMINAL_COLLECTION)
|
||||
@edge(Scene.ACTIVITY_MAIN, Scene.TERMINAL_MAIN)
|
||||
@edge(Scene.ACTIVITY_CHOOSE_LEVEL, Scene.ACTIVITY_MAIN)
|
||||
@edge(Scene.OPERATOR_SUPPORT, Scene.OPERATOR_SELECT)
|
||||
@edge(Scene.OPERATOR_AGENT_SELECT, Scene.OPERATOR_SELECT)
|
||||
@edge(Scene.OPERATOR_SUPPORT_AGENT, Scene.OPERATOR_SUPPORT)
|
||||
@edge(Scene.OPERATOR_ELIMINATE_AGENCY, Scene.OPERATOR_ELIMINATE)
|
||||
def operation_back(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.OPERATOR_GIVEUP, Scene.OPERATOR_FAILED)
|
||||
def operation_give_up(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=1)
|
||||
|
||||
|
||||
@edge(Scene.OPERATOR_FINISH, Scene.OPERATOR_BEFORE)
|
||||
@edge(Scene.OPERATOR_FAILED, Scene.OPERATOR_BEFORE)
|
||||
def operation_finish(solver: BaseSolver):
|
||||
solver.tap((310, 330))
|
||||
|
||||
|
||||
@edge(Scene.UPGRADE, Scene.OPERATOR_FINISH)
|
||||
def upgrade(solver: BaseSolver):
|
||||
solver.tap((960, 540))
|
26
mower/utils/graph/recruit.py
Normal file
26
mower/utils/graph/recruit.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 公招
|
||||
|
||||
|
||||
@edge(Scene.RECRUIT_AGENT, Scene.RECRUIT_MAIN)
|
||||
def recruit_result(solver: BaseSolver):
|
||||
solver.tap((960, 540))
|
||||
|
||||
|
||||
@edge(Scene.REFRESH_TAGS, Scene.RECRUIT_TAGS)
|
||||
def refresh_cancel(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=0)
|
||||
|
||||
|
||||
@edge(Scene.RECRUIT_TAGS, Scene.RECRUIT_MAIN)
|
||||
def recruit_back(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.SKIP, Scene.RECRUIT_AGENT)
|
||||
def skip(solver: BaseSolver):
|
||||
solver.tap_element("skip")
|
75
mower/utils/graph/riic.py
Normal file
75
mower/utils/graph/riic.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
from mower.utils import config
|
||||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 基建
|
||||
|
||||
|
||||
@edge(Scene.INFRA_TODOLIST, Scene.INFRA_MAIN)
|
||||
def todo_complete(solver: BaseSolver):
|
||||
solver.tap((1840, 140))
|
||||
|
||||
|
||||
@edge(Scene.INFRA_CONFIDENTIAL, Scene.INFRA_MAIN)
|
||||
@edge(Scene.INFRA_ARRANGE, Scene.INFRA_MAIN)
|
||||
@edge(Scene.INFRA_DETAILS, Scene.INFRA_MAIN)
|
||||
@edge(Scene.CTRLCENTER_ASSISTANT, Scene.INFRA_MAIN)
|
||||
@edge(Scene.RIIC_OPERATOR_SELECT, Scene.INFRA_DETAILS)
|
||||
@edge(Scene.CLUE_DAILY, Scene.INFRA_CONFIDENTIAL)
|
||||
@edge(Scene.CLUE_RECEIVE, Scene.INFRA_CONFIDENTIAL)
|
||||
@edge(Scene.CLUE_GIVE_AWAY, Scene.INFRA_CONFIDENTIAL)
|
||||
@edge(Scene.CLUE_SUMMARY, Scene.INFRA_CONFIDENTIAL)
|
||||
@edge(Scene.CLUE_PLACE, Scene.INFRA_CONFIDENTIAL)
|
||||
@edge(Scene.ORDER_LIST, Scene.INFRA_DETAILS)
|
||||
@edge(Scene.FACTORY_ROOMS, Scene.INFRA_DETAILS)
|
||||
@edge(Scene.CHOOSE_PRODUCT, Scene.FACTORY_ROOMS)
|
||||
@edge(Scene.DRONE_ACCELERATE, Scene.ORDER_LIST)
|
||||
def infra_back(solver: BaseSolver):
|
||||
solver.cback(1, config.screenshot_avg / 1000)
|
||||
|
||||
|
||||
@edge(Scene.INFRA_ARRANGE_CONFIRM, Scene.RIIC_OPERATOR_SELECT)
|
||||
def infra_arrange_confirm(solver: BaseSolver):
|
||||
solver.tap((1452, 1029))
|
||||
|
||||
|
||||
@edge(Scene.INFRA_ARRANGE_ORDER, Scene.RIIC_OPERATOR_SELECT)
|
||||
def infra_arrange_order(solver: BaseSolver):
|
||||
solver.tap_element("arrange_blue_yes", x_rate=0.66)
|
||||
|
||||
|
||||
@edge(Scene.RIIC_REPORT, Scene.CTRLCENTER_ASSISTANT)
|
||||
def riic_back(solver: BaseSolver):
|
||||
solver.tap((30, 55))
|
||||
|
||||
|
||||
@edge(Scene.CTRLCENTER_ASSISTANT, Scene.RIIC_REPORT)
|
||||
def riic(solver: BaseSolver):
|
||||
solver.tap_element("control_central_assistants")
|
||||
|
||||
|
||||
@edge(Scene.INFRA_MAIN, Scene.CTRLCENTER_ASSISTANT)
|
||||
def control_central(solver: BaseSolver):
|
||||
solver.tap_element("control_central")
|
||||
|
||||
|
||||
@edge(Scene.SANITY_CHARGE, Scene.INFRA_MAIN)
|
||||
def sanity_charge(solver: BaseSolver):
|
||||
solver.tap((1200, 15))
|
||||
|
||||
|
||||
@edge(Scene.SANITY_CHARGE_DIALOG, Scene.SANITY_CHARGE)
|
||||
def sanity_charge_dialog(solver: BaseSolver):
|
||||
solver.tap((480, 925))
|
||||
|
||||
|
||||
@edge(Scene.SWITCH_ORDER, Scene.ORDER_LIST)
|
||||
def switch_order(solver: BaseSolver):
|
||||
solver.tap((1900, 1000))
|
||||
|
||||
|
||||
@edge(Scene.PRODUCT_SWITCHING_CONFIRM, Scene.FACTORY_ROOMS)
|
||||
def product_switching_confirm(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=0)
|
29
mower/utils/graph/shop.py
Normal file
29
mower/utils/graph/shop.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 商店
|
||||
|
||||
|
||||
@edge(Scene.SHOP_OTHERS, Scene.SHOP_CREDIT)
|
||||
@edge(Scene.SHOP_TOKEN, Scene.SHOP_CREDIT)
|
||||
def shop_credit(solver: BaseSolver):
|
||||
solver.switch_shop("credit")
|
||||
|
||||
|
||||
@edge(Scene.SHOP_OTHERS, Scene.SHOP_TOKEN)
|
||||
@edge(Scene.SHOP_CREDIT, Scene.SHOP_TOKEN)
|
||||
def shop_token(solver: BaseSolver):
|
||||
solver.switch_shop("token")
|
||||
|
||||
|
||||
@edge(Scene.SHOP_CREDIT_CONFIRM, Scene.SHOP_CREDIT)
|
||||
@edge(Scene.SHOP_UNLOCK_SCHEDULE, Scene.SHOP_CREDIT)
|
||||
def shop_back(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.SHOP_TRADE_TOKEN, Scene.SHOP_TOKEN)
|
||||
def trade_cancel(solver: BaseSolver):
|
||||
solver.tap_element("shop/trade_token_dialog", x_rate=0)
|
29
mower/utils/graph/sss.py
Normal file
29
mower/utils/graph/sss.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from mower.utils.scene import Scene
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import edge
|
||||
|
||||
# 保全
|
||||
|
||||
|
||||
@edge(Scene.TERMINAL_REGULAR, Scene.SSS_MAIN)
|
||||
def enter_sss(solver: BaseSolver):
|
||||
solver.tap((1548, 870))
|
||||
|
||||
|
||||
@edge(Scene.SSS_MAIN, Scene.TERMINAL_REGULAR)
|
||||
@edge(Scene.SSS_START, Scene.SSS_MAIN)
|
||||
@edge(Scene.SSS_DEPLOY, Scene.SSS_MAIN)
|
||||
@edge(Scene.SSS_EC, Scene.SSS_EXIT_CONFIRM)
|
||||
@edge(Scene.SSS_DEVICE, Scene.SSS_EXIT_CONFIRM)
|
||||
@edge(Scene.SSS_SQUAD, Scene.SSS_EXIT_CONFIRM)
|
||||
@edge(Scene.SSS_DEPLOY, Scene.SSS_MAIN)
|
||||
@edge(Scene.SSS_REDEPLOY, Scene.SSS_MAIN)
|
||||
@edge(Scene.SSS_TERMINATED, Scene.SSS_START)
|
||||
def sss_back(solver: BaseSolver):
|
||||
solver.back()
|
||||
|
||||
|
||||
@edge(Scene.SSS_EXIT_CONFIRM, Scene.SSS_TERMINATED)
|
||||
def sss_exit(solver: BaseSolver):
|
||||
solver.tap_element("double_confirm/main", x_rate=1)
|
30
mower/utils/graph/terminal.py
Normal file
30
mower/utils/graph/terminal.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from mower.utils.solver import BaseSolver
|
||||
|
||||
from .utils import DG
|
||||
|
||||
# 终端
|
||||
|
||||
terminal_button = {
|
||||
501: "main",
|
||||
502: "main_theme",
|
||||
503: "intermezzi",
|
||||
504: "biography",
|
||||
505: "collection",
|
||||
506: "regular",
|
||||
507: "longterm",
|
||||
508: "contract",
|
||||
}
|
||||
|
||||
|
||||
def terminal_transition_factory(t):
|
||||
def terminal_transition(solver: BaseSolver):
|
||||
solver.tap_terminal_button(terminal_button[t])
|
||||
|
||||
return terminal_transition
|
||||
|
||||
|
||||
for s in range(501, 509):
|
||||
for t in range(501, 509):
|
||||
if t == s:
|
||||
continue
|
||||
DG.add_edge(s, t, transition=terminal_transition_factory(t))
|
81
mower/utils/graph/utils.py
Normal file
81
mower/utils/graph/utils.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
import functools
|
||||
|
||||
import networkx as nx
|
||||
|
||||
from mower.utils.csleep import MowerExit
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.scene import Scene, SceneComment
|
||||
from mower.utils.solver import BaseSolver
|
||||
|
||||
DG = nx.DiGraph()
|
||||
|
||||
|
||||
def edge(v_from: int, v_to: int, interval: int | None = None):
|
||||
if interval is None:
|
||||
if Scene.INDEX in [v_from, v_to]:
|
||||
interval = 5
|
||||
else:
|
||||
interval = 1
|
||||
|
||||
def decorator(func):
|
||||
DG.add_edge(v_from, v_to, weight=interval, transition=func)
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kw):
|
||||
return func(*args, **kw)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class SceneGraphSolver(BaseSolver):
|
||||
def scene_graph_navigation(self, scene: int):
|
||||
"""按场景图跳转到指定场景"""
|
||||
|
||||
while self.scene() != scene:
|
||||
self.scene_graph_step(scene)
|
||||
|
||||
def scene_graph_step(self, scene: int):
|
||||
"""waiting_solver()或按场景图跳转到指定场景只操作一步"""
|
||||
if scene not in DG.nodes:
|
||||
logger.error(f"{SceneComment[scene]}不在场景图中")
|
||||
self.sleep()
|
||||
return
|
||||
|
||||
if (current := self.scene()) in self.waiting_scene:
|
||||
if self.waiting_solver():
|
||||
return
|
||||
else:
|
||||
current = self.scene()
|
||||
|
||||
if current == scene:
|
||||
return
|
||||
if current not in DG.nodes:
|
||||
logger.debug(f"{SceneComment[current]}不在场景图中")
|
||||
self.sleep(10)
|
||||
return
|
||||
try:
|
||||
sp = nx.shortest_path(DG, current, scene, weight="weight")
|
||||
except Exception as e:
|
||||
logger.exception(f"场景图路径计算异常:{e}")
|
||||
self.sleep(10)
|
||||
return
|
||||
|
||||
logger.debug(sp)
|
||||
|
||||
next_scene = sp[1]
|
||||
transition = DG.edges[current, next_scene]["transition"]
|
||||
try:
|
||||
transition(self)
|
||||
except MowerExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(f"场景转移异常:{e}")
|
||||
self.sleep()
|
||||
|
||||
def back_to_index(self):
|
||||
self.scene_graph_navigation(Scene.INDEX)
|
||||
|
||||
def back_to_infrastructure(self):
|
||||
self.scene_graph_navigation(Scene.INFRA_MAIN)
|
167
mower/utils/image.py
Normal file
167
mower/utils/image.py
Normal file
|
@ -0,0 +1,167 @@
|
|||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.log import logger, save_screenshot
|
||||
from mower.utils.path import get_path
|
||||
|
||||
|
||||
def bytes2img(data: bytes, gray: bool = False) -> Union[tp.Image, tp.GrayImage]:
|
||||
"""bytes -> image"""
|
||||
if gray:
|
||||
return cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_GRAYSCALE)
|
||||
else:
|
||||
return cv2.cvtColor(
|
||||
cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR),
|
||||
cv2.COLOR_BGR2RGB,
|
||||
)
|
||||
|
||||
|
||||
def img2bytes(img: tp.Image) -> bytes:
|
||||
"""image -> bytes"""
|
||||
return cv2.imencode(
|
||||
".jpg",
|
||||
cv2.cvtColor(img, cv2.COLOR_RGB2BGR),
|
||||
[int(cv2.IMWRITE_JPEG_QUALITY), 75],
|
||||
)[1]
|
||||
|
||||
|
||||
def res2path(res: tp.Res) -> Path:
|
||||
res = f"@install/mower/resources/{res}"
|
||||
if not res.endswith(".jpg"):
|
||||
res += ".png"
|
||||
return get_path(res)
|
||||
|
||||
|
||||
def loadres(res: tp.Res, gray: bool = False) -> Union[tp.Image, tp.GrayImage]:
|
||||
return loadimg(res2path(res), gray)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def loadimg(
|
||||
filename: str, gray: bool = False, bg: tuple[int] = (255, 255, 255, 255)
|
||||
) -> Union[tp.Image, tp.GrayImage]:
|
||||
"""load image from file"""
|
||||
logger.debug(filename)
|
||||
img_data = np.fromfile(filename, dtype=np.uint8)
|
||||
img = cv2.imdecode(img_data, cv2.IMREAD_UNCHANGED)
|
||||
|
||||
if len(img.shape) == 2:
|
||||
if gray:
|
||||
return img
|
||||
else:
|
||||
return cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
|
||||
elif img.shape[2] == 4:
|
||||
pim = Image.fromarray(img)
|
||||
pbg = Image.new("RGBA", pim.size, bg)
|
||||
pbg.paste(pim, (0, 0), pim)
|
||||
if gray:
|
||||
return np.array(pbg.convert("L"))
|
||||
else:
|
||||
return cv2.cvtColor(np.array(pbg.convert("RGB")), cv2.COLOR_BGR2RGB)
|
||||
else:
|
||||
if gray:
|
||||
return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
|
||||
|
||||
|
||||
def thres2(img: tp.GrayImage, thresh: int) -> tp.GrayImage:
|
||||
"""binarization of images"""
|
||||
_, ret = cv2.threshold(img, thresh, 255, cv2.THRESH_BINARY)
|
||||
return ret
|
||||
|
||||
|
||||
# def thres0(img: tp.Image, thresh: int) -> tp.Image:
|
||||
# """ delete pixel, filter: value > thresh """
|
||||
# ret = img.copy()
|
||||
# if len(ret.shape) == 3:
|
||||
# # ret[rgb2gray(img) <= thresh] = 0
|
||||
# z0 = ret[:, :, 0]
|
||||
# z1 = ret[:, :, 1]
|
||||
# z2 = ret[:, :, 2]
|
||||
# _ = (z0 <= thresh) | (z1 <= thresh) | (z2 <= thresh)
|
||||
# z0[_] = 0
|
||||
# z1[_] = 0
|
||||
# z2[_] = 0
|
||||
# else:
|
||||
# ret[ret <= thresh] = 0
|
||||
# return ret
|
||||
|
||||
|
||||
# def thres0(img: tp.Image, thresh: int) -> tp.Image: # not support multichannel image
|
||||
# """ delete pixel which > thresh """
|
||||
# _, ret = cv2.threshold(img, thresh, 255, cv2.THRESH_TOZERO)
|
||||
# return ret
|
||||
|
||||
|
||||
def rgb2gray(img: tp.Image) -> tp.GrayImage:
|
||||
"""change image from rgb to gray"""
|
||||
return cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
|
||||
def scope2slice(scope: tp.Scope) -> tp.Slice:
|
||||
"""((x0, y0), (x1, y1)) -> ((y0, y1), (x0, x1))"""
|
||||
if scope is None:
|
||||
return slice(None), slice(None)
|
||||
return slice(scope[0][1], scope[1][1]), slice(scope[0][0], scope[1][0])
|
||||
|
||||
|
||||
def cropimg(img: tp.Image, scope: tp.Scope) -> tp.Image:
|
||||
"""crop image"""
|
||||
return img[scope2slice(scope)]
|
||||
|
||||
|
||||
def saveimg(img: tp.Image, folder):
|
||||
del folder # 兼容2024.05旧版接口
|
||||
save_screenshot(img2bytes(img))
|
||||
|
||||
|
||||
def cmatch(
|
||||
img1: tp.Image, img2: tp.Image, thresh: int = 10, draw: bool = False
|
||||
) -> bool:
|
||||
"比较平均色"
|
||||
h, w, _ = img1.shape
|
||||
ca = cv2.mean(img1)[:3]
|
||||
cb = cv2.mean(img2)[:3]
|
||||
diff = np.array(ca).astype(int) - np.array(cb).astype(int)
|
||||
diff = np.max(np.maximum(diff, 0)) - np.min(np.minimum(diff, 0))
|
||||
|
||||
if draw:
|
||||
board = np.zeros([h + 5, w * 2, 3], dtype=np.uint8)
|
||||
board[:h, :w, :] = img1
|
||||
board[h:, :w, :] = ca
|
||||
board[:h, w:, :] = img2
|
||||
board[h:, w:, :] = cb
|
||||
|
||||
from matplotlib import pyplot as plt
|
||||
|
||||
plt.imshow(board)
|
||||
plt.show()
|
||||
|
||||
return diff <= thresh
|
||||
|
||||
|
||||
def diff_ratio(
|
||||
img1: tp.GrayImage,
|
||||
img2: tp.GrayImage,
|
||||
thresh: int = 0,
|
||||
ratio: float = 0.05,
|
||||
) -> bool:
|
||||
"""计算两张灰图之间不同的像素所占比例
|
||||
|
||||
Args:
|
||||
img1: 一张灰图
|
||||
img2: 另一张灰图
|
||||
thresh: 认为有差别的阈值
|
||||
ratio: 有差别的像素比例阈值
|
||||
"""
|
||||
h, w = img1.shape
|
||||
diff = cv2.absdiff(img1, img2)
|
||||
diff = thres2(diff, thresh)
|
||||
return cv2.countNonZero(diff) > ratio * w * h
|
114
mower/utils/log.py
Normal file
114
mower/utils/log.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from logging.handlers import QueueHandler, QueueListener, TimedRotatingFileHandler
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
import colorlog
|
||||
|
||||
from mower.utils import config
|
||||
from mower.utils.path import get_path
|
||||
|
||||
BASIC_FORMAT = (
|
||||
"%(asctime)s %(relativepath)s:%(lineno)d %(levelname)s %(funcName)s: %(message)s"
|
||||
)
|
||||
COLOR_FORMAT = f"%(log_color)s{BASIC_FORMAT}"
|
||||
DATE_FORMAT = None
|
||||
basic_formatter = logging.Formatter(BASIC_FORMAT, DATE_FORMAT)
|
||||
color_formatter = colorlog.ColoredFormatter(COLOR_FORMAT, DATE_FORMAT)
|
||||
|
||||
|
||||
class PackagePathFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
relativepath = Path(record.pathname)
|
||||
try:
|
||||
relativepath = relativepath.relative_to(get_path("@install"))
|
||||
except ValueError:
|
||||
pass
|
||||
record.relativepath = relativepath
|
||||
return True
|
||||
|
||||
|
||||
filter = PackagePathFilter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# d(ebug)hlr: 终端输出
|
||||
dhlr = logging.StreamHandler(stream=sys.stdout)
|
||||
dhlr.setFormatter(color_formatter)
|
||||
dhlr.setLevel(logging.DEBUG)
|
||||
dhlr.addFilter(filter)
|
||||
|
||||
# f(ile)hlr: 文件记录
|
||||
folder = Path(get_path("@app/log"))
|
||||
folder.mkdir(exist_ok=True, parents=True)
|
||||
fhlr = TimedRotatingFileHandler(
|
||||
folder.joinpath("runtime.log"), encoding="utf8", backupCount=168
|
||||
)
|
||||
fhlr.setFormatter(basic_formatter)
|
||||
fhlr.setLevel("DEBUG")
|
||||
fhlr.addFilter(filter)
|
||||
|
||||
|
||||
class Handler(logging.StreamHandler):
|
||||
def emit(self, record: logging.LogRecord):
|
||||
msg = f"{record.asctime} {record.levelname} {record.message}"
|
||||
if record.exc_info:
|
||||
msg += "\n" + "".join(traceback.format_exception(*record.exc_info))
|
||||
config.log_queue.put(msg)
|
||||
|
||||
|
||||
# w(ebsocket)hlr: WebSocket
|
||||
whlr = Handler()
|
||||
whlr.setLevel(logging.INFO)
|
||||
|
||||
log_queue = Queue()
|
||||
queue_handler = QueueHandler(log_queue)
|
||||
logger.addHandler(queue_handler)
|
||||
listener = QueueListener(log_queue, dhlr, fhlr, whlr, respect_handler_level=True)
|
||||
listener.start()
|
||||
|
||||
screenshot_folder = get_path("@app/screenshot")
|
||||
screenshot_folder.mkdir(exist_ok=True, parents=True)
|
||||
screenshot_queue = Queue()
|
||||
cleanup_time = datetime.now()
|
||||
|
||||
|
||||
def screenshot_cleanup():
|
||||
logger.info("清理过期截图")
|
||||
start_time_ns = time.time_ns() - config.conf.screenshot * 3600 * 10**9
|
||||
for i in screenshot_folder.iterdir():
|
||||
if i.is_dir():
|
||||
shutil.rmtree(i)
|
||||
elif not i.stem.isnumeric():
|
||||
i.unlink()
|
||||
elif int(i.stem) < start_time_ns:
|
||||
i.unlink()
|
||||
global cleanup_time
|
||||
cleanup_time = datetime.now()
|
||||
|
||||
|
||||
def screenshot_worker():
|
||||
screenshot_cleanup()
|
||||
while True:
|
||||
now = datetime.now()
|
||||
if now - cleanup_time > timedelta(hours=1):
|
||||
screenshot_cleanup()
|
||||
img, filename = screenshot_queue.get()
|
||||
with screenshot_folder.joinpath(filename).open("wb") as f:
|
||||
f.write(img)
|
||||
|
||||
|
||||
Thread(target=screenshot_worker, daemon=True).start()
|
||||
|
||||
|
||||
def save_screenshot(img: bytes) -> None:
|
||||
filename = f"{time.time_ns()}.jpg"
|
||||
logger.debug(filename)
|
||||
screenshot_queue.put((img, filename))
|
25
mower/utils/logic_expression.py
Normal file
25
mower/utils/logic_expression.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from typing import Optional, Self
|
||||
|
||||
|
||||
class LogicExpression:
|
||||
def __init__(
|
||||
self,
|
||||
left: Optional[str | Self] = None,
|
||||
operator: Optional[str] = None,
|
||||
right: Optional[str | Self] = None,
|
||||
):
|
||||
self.operator = operator
|
||||
self.left = left
|
||||
self.right = right
|
||||
|
||||
def __str__(self):
|
||||
return f"({(self.left)} {self.operator} {(self.right)})"
|
||||
|
||||
|
||||
def get_logic_exp(trigger: dict) -> LogicExpression:
|
||||
for k in ["left", "operator", "right"]:
|
||||
if k not in trigger:
|
||||
trigger[k] = ""
|
||||
if not isinstance(trigger[k], str):
|
||||
trigger[k] = get_logic_exp(trigger[k])
|
||||
return LogicExpression(trigger["left"], trigger["operator"], trigger["right"])
|
626
mower/utils/matcher.py
Normal file
626
mower/utils/matcher.py
Normal file
|
@ -0,0 +1,626 @@
|
|||
import lzma
|
||||
import pickle
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import sklearn.pipeline # noqa
|
||||
import sklearn.preprocessing
|
||||
import sklearn.svm # noqa
|
||||
from skimage.metrics import structural_similarity as compare_ssim
|
||||
|
||||
from mower import __rootdir__
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.deprecated import deprecated
|
||||
from mower.utils.image import cropimg
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.vector import va, vs
|
||||
|
||||
GOOD_DISTANCE_LIMIT = 0.7
|
||||
|
||||
ORB = cv2.ORB_create(nfeatures=100000, edgeThreshold=0)
|
||||
ORB_no_pyramid = cv2.ORB_create(nfeatures=100000, edgeThreshold=0, nlevels=1)
|
||||
|
||||
|
||||
def keypoints_scale_invariant(img: tp.GrayImage):
|
||||
return ORB.detectAndCompute(img, None)
|
||||
|
||||
|
||||
def keypoints(img: tp.GrayImage):
|
||||
return ORB_no_pyramid.detectAndCompute(img, None)
|
||||
|
||||
|
||||
with lzma.open(f"{__rootdir__}/models/svm.model", "rb") as f:
|
||||
SVC = pickle.loads(f.read())
|
||||
|
||||
|
||||
# build FlannBasedMatcher
|
||||
|
||||
# FLANN_INDEX_KDTREE = 1
|
||||
FLANN_INDEX_LSH = 6
|
||||
|
||||
index_params = dict(
|
||||
algorithm=FLANN_INDEX_LSH,
|
||||
table_number=1,
|
||||
key_size=6,
|
||||
multi_probe_level=0,
|
||||
)
|
||||
search_params = dict(checks=50) # 100
|
||||
flann = cv2.FlannBasedMatcher(index_params, search_params)
|
||||
|
||||
|
||||
def getHash(data: list[float]) -> tp.Hash:
|
||||
"""calc image hash"""
|
||||
avreage = np.mean(data)
|
||||
return np.where(data > avreage, 1, 0)
|
||||
|
||||
|
||||
def hammingDistance(hash1: tp.Hash, hash2: tp.Hash) -> int:
|
||||
"""calc Hamming distance between two hash"""
|
||||
return np.count_nonzero(hash1 != hash2)
|
||||
|
||||
|
||||
def aHash(img1: tp.GrayImage, img2: tp.GrayImage) -> int:
|
||||
"""calc image hash"""
|
||||
data1 = cv2.resize(img1, (8, 4)).flatten()
|
||||
data2 = cv2.resize(img2, (8, 4)).flatten()
|
||||
hash1 = getHash(data1)
|
||||
hash2 = getHash(data2)
|
||||
return hammingDistance(hash1, hash2)
|
||||
|
||||
|
||||
class Matcher:
|
||||
def __init__(self, origin: tp.GrayImage) -> None:
|
||||
logger.debug(f"{origin.shape=}")
|
||||
self.origin = origin
|
||||
self.kp, self.des = keypoints(self.origin)
|
||||
|
||||
def in_scope(self, scope):
|
||||
if scope is None:
|
||||
return self.kp, self.des
|
||||
ori_kp, ori_des = [], []
|
||||
for _kp, _des in zip(self.kp, self.des):
|
||||
if (
|
||||
scope[0][0] <= _kp.pt[0] <= scope[1][0]
|
||||
and scope[0][1] <= _kp.pt[1] <= scope[1][1]
|
||||
):
|
||||
ori_kp.append(_kp)
|
||||
ori_des.append(_des)
|
||||
logger.debug(f"{scope=}, {len(self.kp)=} -> {len(ori_kp)=}")
|
||||
return np.array(ori_kp), np.array(ori_des)
|
||||
|
||||
@deprecated
|
||||
def match_old(
|
||||
self,
|
||||
query: tp.GrayImage,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope = None,
|
||||
dpi_aware: bool = False,
|
||||
prescore: float = 0.0,
|
||||
judge: bool = True,
|
||||
) -> Optional[tp.Scope]:
|
||||
"""check if the image can be matched"""
|
||||
rect_score = self.score(
|
||||
query,
|
||||
draw,
|
||||
scope,
|
||||
only_score=False,
|
||||
dpi_aware=dpi_aware,
|
||||
) # get matching score
|
||||
if rect_score is None:
|
||||
return None # failed in matching
|
||||
else:
|
||||
rect, score = rect_score
|
||||
|
||||
if prescore > 0:
|
||||
if score[3] >= prescore:
|
||||
logger.debug(f"{score[3]=} >= {prescore=}")
|
||||
return rect
|
||||
else:
|
||||
logger.debug(f"{score[3]=} < {prescore=}")
|
||||
return None
|
||||
if judge and not SVC.predict([score])[0]:
|
||||
logger.debug(f"{judge=} {SVC.predict([score])[0]=}")
|
||||
return None
|
||||
logger.debug(f"{rect=}")
|
||||
return rect
|
||||
|
||||
def score(
|
||||
self,
|
||||
query: tp.GrayImage,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope = None,
|
||||
only_score: bool = False,
|
||||
dpi_aware: bool = False,
|
||||
) -> Optional[Tuple[tp.Scope, tp.Score]]:
|
||||
"""scoring of image matching"""
|
||||
try:
|
||||
# if feature points is empty
|
||||
if self.des is None:
|
||||
logger.debug(f"{self.des=}")
|
||||
return None
|
||||
|
||||
# specify the crop scope
|
||||
if scope is not None:
|
||||
ori_kp, ori_des = [], []
|
||||
for _kp, _des in zip(self.kp, self.des):
|
||||
if (
|
||||
scope[0][0] <= _kp.pt[0]
|
||||
and scope[0][1] <= _kp.pt[1]
|
||||
and _kp.pt[0] <= scope[1][0]
|
||||
and _kp.pt[1] <= scope[1][1]
|
||||
):
|
||||
ori_kp.append(_kp)
|
||||
ori_des.append(_des)
|
||||
logger.debug(f"{scope=}, {len(self.kp)=} -> {len(ori_kp)=}")
|
||||
ori_kp, ori_des = np.array(ori_kp), np.array(ori_des)
|
||||
else:
|
||||
ori_kp, ori_des = self.kp, self.des
|
||||
|
||||
# if feature points is less than 2
|
||||
if len(ori_kp) < 2:
|
||||
logger.debug(f"{len(ori_kp)=} < 2")
|
||||
return None
|
||||
|
||||
# the height & width of query image
|
||||
h, w = query.shape
|
||||
|
||||
# the feature point of query image
|
||||
if dpi_aware:
|
||||
qry_kp, qry_des = keypoints_scale_invariant(query)
|
||||
else:
|
||||
qry_kp, qry_des = keypoints(query)
|
||||
|
||||
matches = flann.knnMatch(qry_des, ori_des, k=2)
|
||||
|
||||
# store all the good matches as per Lowe's ratio test
|
||||
good = []
|
||||
for pair in matches:
|
||||
if (len_pair := len(pair)) == 2:
|
||||
x, y = pair
|
||||
if x.distance < GOOD_DISTANCE_LIMIT * y.distance:
|
||||
good.append(x)
|
||||
elif len_pair == 1:
|
||||
good.append(pair[0])
|
||||
good_matches_rate = len(good) / len(qry_des)
|
||||
|
||||
# draw all the good matches, for debug
|
||||
if draw:
|
||||
result = cv2.drawMatches(query, qry_kp, self.origin, ori_kp, good, None)
|
||||
|
||||
from matplotlib import pyplot as plt
|
||||
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
# if the number of good matches no more than 4
|
||||
if len(good) <= 4:
|
||||
logger.debug(f"{len(good)=} <= 4, {len(qry_des)=}")
|
||||
return None
|
||||
|
||||
# get the coordinates of good matches
|
||||
qry_pts = np.int32([qry_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
ori_pts = np.int32([ori_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
|
||||
# calculated transformation matrix and the mask
|
||||
M, mask = cv2.estimateAffine2D(qry_pts, ori_pts, None, cv2.RANSAC)
|
||||
|
||||
# if transformation matrix is None
|
||||
if M is None:
|
||||
logger.debug("M is None")
|
||||
return None
|
||||
else:
|
||||
logger.debug(f"M={M.tolist()}")
|
||||
|
||||
M[0][1] = 0
|
||||
M[1][0] = 0
|
||||
avg = (M[0][0] + M[1][1]) / 2
|
||||
M[0][0] = avg
|
||||
M[1][1] = avg
|
||||
|
||||
# calc the location of the query image
|
||||
# quad = np.float32([[[0, 0]], [[0, h-1]], [[w-1, h-1]], [[w-1, 0]]])
|
||||
quad = np.int32([[[0, 0]], [[w, h]]])
|
||||
quad = cv2.transform(quad, M) # quadrangle
|
||||
rect = quad.reshape(2, 2).tolist()
|
||||
|
||||
# draw the result, for debug
|
||||
if draw:
|
||||
matchesMask = mask.ravel().tolist()
|
||||
origin_copy = cv2.cvtColor(self.origin, cv2.COLOR_GRAY2RGB)
|
||||
cv2.rectangle(origin_copy, rect[0], rect[1], (255, 0, 0), 3)
|
||||
draw_params = dict(
|
||||
matchColor=(0, 255, 0),
|
||||
singlePointColor=None,
|
||||
matchesMask=matchesMask,
|
||||
flags=2,
|
||||
)
|
||||
result = cv2.drawMatches(
|
||||
query, qry_kp, origin_copy, ori_kp, good, None, **draw_params
|
||||
)
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
|
||||
min_width = max(10, 0 if dpi_aware else w * 0.8)
|
||||
min_height = max(10, 0 if dpi_aware else h * 0.8)
|
||||
|
||||
rect_w = rect[1][0] - rect[0][0]
|
||||
rect_h = rect[1][1] - rect[0][1]
|
||||
if rect_w < min_width or rect_h < min_height:
|
||||
logger.debug(f"{rect_w=}x{rect_h=} < {min_width=}x{min_height=}")
|
||||
return None
|
||||
|
||||
if not dpi_aware:
|
||||
max_width = w * 1.25
|
||||
max_height = h * 1.25
|
||||
if rect_w > max_width or rect_h > max_height:
|
||||
logger.debug(f"{rect_w=}x{rect_h=} > {max_width=}x{max_height=}")
|
||||
return None
|
||||
|
||||
# measure the rate of good match within the rectangle (x-axis)
|
||||
better = filter(
|
||||
lambda m: rect[0][0] < ori_kp[m.trainIdx].pt[0] < rect[1][0]
|
||||
and rect[0][1] < ori_kp[m.trainIdx].pt[1] < rect[1][1],
|
||||
good,
|
||||
)
|
||||
better_kp_x = [qry_kp[m.queryIdx].pt[0] for m in better]
|
||||
if len(better_kp_x):
|
||||
good_area_rate = np.ptp(better_kp_x) / w
|
||||
else:
|
||||
good_area_rate = 0
|
||||
|
||||
# rectangle: float -> int
|
||||
rect = np.array(rect, dtype=int).tolist()
|
||||
rect_img = cropimg(self.origin, rect)
|
||||
|
||||
# if rect_img is too small
|
||||
if rect_img.shape[0] < min_height or rect_img.shape[1] < min_width:
|
||||
logger.debug(f"{rect_img.shape=} < {min_width=}x{min_height=}")
|
||||
return None
|
||||
|
||||
# transpose rect_img
|
||||
rect_img = cv2.resize(rect_img, query.shape[::-1])
|
||||
|
||||
# draw the result
|
||||
if draw:
|
||||
plt.subplot(1, 2, 1)
|
||||
plt.imshow(query, cmap="gray", vmin=0, vmax=255)
|
||||
plt.subplot(1, 2, 2)
|
||||
plt.imshow(rect_img, cmap="gray", vmin=0, vmax=255)
|
||||
plt.show()
|
||||
|
||||
# calc aHash between query image and rect_img
|
||||
hash = 1 - (aHash(query, rect_img) / 16)
|
||||
|
||||
# calc ssim between query image and rect_img
|
||||
ssim = compare_ssim(query, rect_img, multichannel=True)
|
||||
|
||||
# return final rectangle and four dimensions of scoring
|
||||
result = good_matches_rate, good_area_rate, hash, ssim
|
||||
if not only_score:
|
||||
result = rect, result
|
||||
logger.debug(result)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
def match(
|
||||
self,
|
||||
query: tp.GrayImage,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope | None = None,
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
if self.des is None:
|
||||
logger.debug(f"{self.des=}")
|
||||
return -1, None
|
||||
ori_kp, ori_des = self.in_scope(scope)
|
||||
if len(ori_kp) < 2:
|
||||
logger.debug(f"{len(ori_kp)=} < 2")
|
||||
return -1, None
|
||||
|
||||
qry_kp, qry_des = keypoints(query)
|
||||
matches = flann.knnMatch(qry_des, ori_des, k=2)
|
||||
good = []
|
||||
for pair in matches:
|
||||
if (len_pair := len(pair)) == 2:
|
||||
x, y = pair
|
||||
if x.distance < GOOD_DISTANCE_LIMIT * y.distance:
|
||||
good.append(x)
|
||||
elif len_pair == 1:
|
||||
good.append(pair[0])
|
||||
|
||||
if draw:
|
||||
from matplotlib import pyplot as plt
|
||||
|
||||
result = cv2.drawMatches(query, qry_kp, self.origin, ori_kp, good, None)
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
|
||||
if len(good) <= 4:
|
||||
logger.debug(f"{len(good)=} <= 4, {len(qry_des)=}")
|
||||
return -1, None
|
||||
|
||||
qry_pts = np.float32([qry_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
ori_pts = np.float32([ori_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
M, mask = cv2.estimateAffine2D(qry_pts, ori_pts, None, cv2.RANSAC)
|
||||
|
||||
if M is None:
|
||||
logger.debug("M is None")
|
||||
return -1, None
|
||||
else:
|
||||
logger.debug(f"M={M.tolist()}")
|
||||
|
||||
M[0][1] = M[1][0] = 0
|
||||
M[0][0] = M[1][1] = 1
|
||||
|
||||
h, w = query.shape
|
||||
|
||||
if draw:
|
||||
matches_mask = mask.ravel().tolist()
|
||||
pts = np.int32([[[0, 0]], [[w - 1, h - 1]]])
|
||||
dst = np.int32(cv2.transform(pts, M)).tolist()
|
||||
dst = [i[0] for i in dst]
|
||||
dst = np.int32(
|
||||
[
|
||||
[dst[0]],
|
||||
[[dst[0][0], dst[1][1]]],
|
||||
[dst[1]],
|
||||
[[dst[1][0], dst[0][1]]],
|
||||
]
|
||||
)
|
||||
disp = cv2.cvtColor(self.origin, cv2.COLOR_GRAY2RGB)
|
||||
disp = cv2.polylines(disp, [dst], True, (255, 0, 0), 2, cv2.LINE_AA)
|
||||
disp = cv2.drawMatches(
|
||||
query,
|
||||
qry_kp,
|
||||
disp,
|
||||
ori_kp,
|
||||
good,
|
||||
None,
|
||||
(0, 255, 0),
|
||||
matchesMask=matches_mask,
|
||||
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS,
|
||||
)
|
||||
plt.imshow(disp)
|
||||
plt.show()
|
||||
|
||||
pts = np.int32([[[-20, -20]], [[w + 19, h + 19]]])
|
||||
dst = cv2.transform(pts, M)
|
||||
rect = dst.reshape(2, 2).tolist()
|
||||
rect = np.array(rect, dtype=int).tolist()
|
||||
disp = cropimg(self.origin, rect)
|
||||
rh, rw = disp.shape
|
||||
if rh < h or rw < w:
|
||||
logger.debug(f"{rect=} {rh=} {h=} {rw=} {w=}")
|
||||
return -1, None
|
||||
|
||||
result = cv2.matchTemplate(disp, query, cv2.TM_CCOEFF_NORMED)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
|
||||
if draw:
|
||||
disp = cropimg(disp, (max_loc, va(max_loc, (w, h))))
|
||||
plt.subplot(1, 2, 1)
|
||||
plt.imshow(query, cmap="gray", vmin=0, vmax=255)
|
||||
plt.subplot(1, 2, 2)
|
||||
plt.imshow(disp, cmap="gray", vmin=0, vmax=255)
|
||||
plt.show()
|
||||
|
||||
top_left = va(rect[0], max_loc)
|
||||
scope = top_left, va(top_left, (w, h))
|
||||
logger.debug(f"{max_val=} {scope=}")
|
||||
return max_val, scope
|
||||
|
||||
def match2d(
|
||||
self,
|
||||
query: tp.GrayImage,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope | None = None,
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
if self.des is None:
|
||||
logger.debug(f"{self.des=}")
|
||||
return -1, None
|
||||
ori_kp, ori_des = self.in_scope(scope)
|
||||
if len(ori_kp) < 2:
|
||||
logger.debug(f"{len(ori_kp)=} < 2")
|
||||
return -1, None
|
||||
|
||||
qry_kp, qry_des = keypoints_scale_invariant(query)
|
||||
matches = flann.knnMatch(qry_des, ori_des, k=2)
|
||||
good = []
|
||||
for pair in matches:
|
||||
if (len_pair := len(pair)) == 2:
|
||||
x, y = pair
|
||||
if x.distance < GOOD_DISTANCE_LIMIT * y.distance:
|
||||
good.append(x)
|
||||
elif len_pair == 1:
|
||||
good.append(pair[0])
|
||||
|
||||
if draw:
|
||||
from matplotlib import pyplot as plt
|
||||
|
||||
result = cv2.drawMatches(query, qry_kp, self.origin, ori_kp, good, None)
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
|
||||
if len(good) <= 4:
|
||||
logger.debug(f"{len(good)=} <= 4, {len(qry_des)=}")
|
||||
return -1, None
|
||||
|
||||
qry_pts = np.float32([qry_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
ori_pts = np.float32([ori_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
M, mask = cv2.estimateAffine2D(qry_pts, ori_pts, None, cv2.RANSAC)
|
||||
|
||||
if M is None:
|
||||
logger.debug("M is None")
|
||||
return -1, None
|
||||
else:
|
||||
logger.debug(f"M={M.tolist()}")
|
||||
|
||||
M[0][1] = M[1][0] = 0
|
||||
M[0][0] = M[1][1] = scale = (M[0][0] + M[1][1]) / 2
|
||||
|
||||
logger.debug(f"{scale=}")
|
||||
|
||||
h, w = query.shape
|
||||
|
||||
if draw:
|
||||
matches_mask = mask.ravel().tolist()
|
||||
pts = np.int32([[[0, 0]], [[w - 1, h - 1]]])
|
||||
dst = np.int32(cv2.transform(pts, M)).tolist()
|
||||
dst = [i[0] for i in dst]
|
||||
dst = np.int32(
|
||||
[
|
||||
[dst[0]],
|
||||
[[dst[0][0], dst[1][1]]],
|
||||
[dst[1]],
|
||||
[[dst[1][0], dst[0][1]]],
|
||||
]
|
||||
)
|
||||
disp = cv2.cvtColor(self.origin, cv2.COLOR_GRAY2RGB)
|
||||
disp = cv2.polylines(disp, [dst], True, (255, 0, 0), 2, cv2.LINE_AA)
|
||||
disp = cv2.drawMatches(
|
||||
query,
|
||||
qry_kp,
|
||||
disp,
|
||||
ori_kp,
|
||||
good,
|
||||
None,
|
||||
(0, 255, 0),
|
||||
matchesMask=matches_mask,
|
||||
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS,
|
||||
)
|
||||
plt.imshow(disp)
|
||||
plt.show()
|
||||
|
||||
pts = np.int32([[[-20, -20]], [[w + 19, h + 19]]])
|
||||
dst = cv2.transform(pts, M)
|
||||
rect = dst.reshape(2, 2).tolist()
|
||||
rect = np.array(rect, dtype=int).tolist()
|
||||
rect_img = cropimg(self.origin, rect)
|
||||
disp = cv2.resize(rect_img, dsize=None, fx=1 / scale, fy=1 / scale)
|
||||
result = cv2.matchTemplate(disp, query, cv2.TM_CCOEFF_NORMED)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
|
||||
if draw:
|
||||
disp = cropimg(disp, (max_loc, va(max_loc, (w, h))))
|
||||
plt.subplot(1, 2, 1)
|
||||
plt.imshow(query, cmap="gray", vmin=0, vmax=255)
|
||||
plt.subplot(1, 2, 2)
|
||||
plt.imshow(disp, cmap="gray", vmin=0, vmax=255)
|
||||
plt.show()
|
||||
|
||||
top_left = vs(max_loc, (20, 20))
|
||||
scope = top_left, va(top_left, (w, h))
|
||||
scope = np.float32(scope).reshape(-1, 1, 2)
|
||||
scope = cv2.transform(scope, M)
|
||||
scope = np.int32(scope).reshape(2, 2).tolist()
|
||||
logger.debug(f"{max_val=} {scope=}")
|
||||
return max_val, scope
|
||||
|
||||
def match3d(
|
||||
self,
|
||||
query: tp.GrayImage,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope | None = None,
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
if self.des is None:
|
||||
logger.debug(f"{self.des=}")
|
||||
return -1, None
|
||||
ori_kp, ori_des = self.in_scope(scope)
|
||||
if len(ori_kp) < 2:
|
||||
logger.debug(f"{len(ori_kp)=} < 2")
|
||||
return -1, None
|
||||
|
||||
qry_kp, qry_des = keypoints_scale_invariant(query)
|
||||
matches = flann.knnMatch(qry_des, ori_des, k=2)
|
||||
good = []
|
||||
for pair in matches:
|
||||
if (len_pair := len(pair)) == 2:
|
||||
x, y = pair
|
||||
if x.distance < GOOD_DISTANCE_LIMIT * y.distance:
|
||||
good.append(x)
|
||||
elif len_pair == 1:
|
||||
good.append(pair[0])
|
||||
|
||||
if draw:
|
||||
from matplotlib import pyplot as plt
|
||||
|
||||
result = cv2.drawMatches(query, qry_kp, self.origin, ori_kp, good, None)
|
||||
plt.imshow(result)
|
||||
plt.show()
|
||||
|
||||
if len(good) <= 4:
|
||||
logger.debug(f"{len(good)=} <= 4, {len(qry_des)=}")
|
||||
return -1, None
|
||||
|
||||
qry_pts = np.float32([qry_kp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
ori_pts = np.float32([ori_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
||||
M, mask = cv2.findHomography(qry_pts, ori_pts, cv2.RANSAC)
|
||||
|
||||
if M is None:
|
||||
logger.debug("M is None")
|
||||
return -1, None
|
||||
else:
|
||||
logger.debug(f"M={M.tolist()}")
|
||||
|
||||
h, w = query.shape
|
||||
|
||||
if draw:
|
||||
matches_mask = mask.ravel().tolist()
|
||||
pts = np.float32(
|
||||
[
|
||||
[0, 0],
|
||||
[0, h - 1],
|
||||
[w - 1, h - 1],
|
||||
[w - 1, 0],
|
||||
]
|
||||
).reshape(-1, 1, 2)
|
||||
dst = cv2.perspectiveTransform(pts, M)
|
||||
disp = cv2.cvtColor(self.origin, cv2.COLOR_GRAY2RGB)
|
||||
disp = cv2.polylines(
|
||||
disp, [np.int32(dst)], True, (255, 0, 0), 2, cv2.LINE_AA
|
||||
)
|
||||
disp = cv2.drawMatches(
|
||||
query,
|
||||
qry_kp,
|
||||
disp,
|
||||
ori_kp,
|
||||
good,
|
||||
None,
|
||||
(0, 255, 0),
|
||||
matchesMask=matches_mask,
|
||||
flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS,
|
||||
)
|
||||
plt.imshow(disp)
|
||||
plt.show()
|
||||
|
||||
offset = (20, 20)
|
||||
A = np.array(
|
||||
[
|
||||
[1, 0, -offset[0]],
|
||||
[0, 1, -offset[1]],
|
||||
[0, 0, 1],
|
||||
]
|
||||
)
|
||||
|
||||
disp = cv2.warpPerspective(
|
||||
self.origin, M.dot(A), va((w, h), (40, 40)), None, cv2.WARP_INVERSE_MAP
|
||||
)
|
||||
result = cv2.matchTemplate(disp, query, cv2.TM_CCOEFF_NORMED)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
|
||||
if draw:
|
||||
disp = cropimg(disp, (max_loc, va(max_loc, (w, h))))
|
||||
plt.subplot(1, 2, 1)
|
||||
plt.imshow(query, cmap="gray", vmin=0, vmax=255)
|
||||
plt.subplot(1, 2, 2)
|
||||
plt.imshow(disp, cmap="gray", vmin=0, vmax=255)
|
||||
plt.show()
|
||||
|
||||
top_left = vs(max_loc, offset)
|
||||
scope = top_left, va(top_left, (w, h))
|
||||
scope = np.float32(scope).reshape(-1, 1, 2)
|
||||
scope = cv2.perspectiveTransform(scope, M)
|
||||
scope = np.int32(scope).reshape(2, 2).tolist()
|
||||
logger.debug(f"{max_val=} {scope=}")
|
||||
return max_val, scope
|
13
mower/utils/network.py
Normal file
13
mower/utils/network.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
import socket
|
||||
|
||||
|
||||
def is_port_in_use(port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(0.1)
|
||||
return s.connect_ex(("localhost", port)) == 0
|
||||
|
||||
|
||||
def get_new_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("localhost", 0))
|
||||
return s.getsockname()[1]
|
42
mower/utils/news.py
Normal file
42
mower/utils/news.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
def get_update_time():
|
||||
host = "https://ak.hypergryph.com"
|
||||
url = host + "/news/"
|
||||
response = requests.get(url)
|
||||
response.encoding = "utf8"
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
soup.encode("utf-8")
|
||||
|
||||
for h1 in soup.find_all("h1"):
|
||||
if "闪断更新公告" in h1.text:
|
||||
pattern = r"(\d+)月(\d+)日(\d+):(\d+)"
|
||||
result = re.findall(pattern, h1.text)[0]
|
||||
result = list(map(int, result))
|
||||
now = datetime.now()
|
||||
update_time = datetime(now.year, result[0], result[1], result[2], result[3])
|
||||
logger.debug(f"闪断更新时间:{update_time}")
|
||||
|
||||
if now > update_time:
|
||||
logger.info("闪断更新时间已过")
|
||||
else:
|
||||
delta = update_time - now
|
||||
msg = "距离闪断更新的时间:"
|
||||
if delta.days > 0:
|
||||
msg += f"{delta.days}天{delta.seconds // 3600}小时"
|
||||
else:
|
||||
h = delta.seconds // 3600
|
||||
m = (delta.seconds - h * 3600) // 60
|
||||
msg += f"{h}小时{m}分钟"
|
||||
link = h1.parent["href"]
|
||||
msg += ";更新公告:" + host + link
|
||||
logger.info(msg)
|
||||
return update_time
|
||||
return None
|
788
mower/utils/operators.py
Normal file
788
mower/utils/operators.py
Normal file
|
@ -0,0 +1,788 @@
|
|||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from evalidate import Expr, base_eval_model
|
||||
|
||||
from mower.utils.plan import PlanConfig
|
||||
|
||||
from ..data import agent_arrange_order, agent_list, base_room_list
|
||||
from ..solvers.infra.record import save_action_to_sqlite_decorator
|
||||
from ..utils.log import logger
|
||||
|
||||
|
||||
class SkillUpgradeSupport:
|
||||
support_class = None
|
||||
level = 1
|
||||
efficiency = 0
|
||||
half_off = False
|
||||
add_on = False
|
||||
match = False
|
||||
use_booster = True
|
||||
name = ""
|
||||
swap_name = ""
|
||||
|
||||
def __init__(self, name, skill_level, efficiency, match, swap_name="艾丽妮"):
|
||||
self.name = name
|
||||
self.level = skill_level
|
||||
self.efficiency = efficiency
|
||||
self.match = match
|
||||
if self.level > 1:
|
||||
self.half_off = True
|
||||
self.swap_name = swap_name
|
||||
|
||||
|
||||
class Operators:
|
||||
config = None
|
||||
operators = None
|
||||
exhaust_agent = []
|
||||
exhaust_group = []
|
||||
groups = None
|
||||
dorm = []
|
||||
plan = None
|
||||
|
||||
global_plan = None
|
||||
plan_condition = []
|
||||
shadow_copy = {}
|
||||
current_room_changed_callback = None
|
||||
first_init = True
|
||||
skill_upgrade_supports = []
|
||||
|
||||
def __init__(self, plan):
|
||||
self.operators = {}
|
||||
self.groups = {}
|
||||
self.exhaust_agent = []
|
||||
self.exhaust_group = []
|
||||
self.dorm = []
|
||||
self.workaholic_agent = []
|
||||
self.free_blacklist = []
|
||||
self.global_plan = plan
|
||||
self.backup_plans = plan["backup_plans"]
|
||||
# 切换默认排班
|
||||
self.swap_plan([False] * (len(self.backup_plans)))
|
||||
self.run_order_rooms = {}
|
||||
self.clues = []
|
||||
self.current_room_changed_callback = None
|
||||
self.party_time = None
|
||||
|
||||
self.eval_model = base_eval_model.clone()
|
||||
self.eval_model.nodes.extend(["Call", "Attribute"])
|
||||
self.eval_model.attributes.extend(
|
||||
[
|
||||
"operators",
|
||||
"party_time",
|
||||
"is_working",
|
||||
"is_resting",
|
||||
"current_mood",
|
||||
"current_room",
|
||||
]
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Operators(operators={self.operators})"
|
||||
|
||||
def calculate_switch_time(self, support: SkillUpgradeSupport):
|
||||
hour = 0
|
||||
half_off = support.half_off
|
||||
level = support.level
|
||||
match = support.match
|
||||
efficiency = support.efficiency
|
||||
same = support.name == support.swap_name
|
||||
if level == 1:
|
||||
half_off = False
|
||||
# if left_minutes > 0 or left_hours > 0:
|
||||
# hour = left_minutes / 60 + left_hours
|
||||
# 基本5%
|
||||
basic = 5
|
||||
if support.add_on:
|
||||
# 阿斯卡伦
|
||||
basic += 5
|
||||
if hour == 0:
|
||||
hour = level * 8
|
||||
if half_off:
|
||||
hour = hour / 2
|
||||
left = 0
|
||||
if not same:
|
||||
left = 5 * (100 + basic + (30 if match else 0)) / 100
|
||||
left = hour - left
|
||||
else:
|
||||
left = hour
|
||||
return left * 100 / (100 + efficiency + basic)
|
||||
|
||||
def swap_plan(self, condition, refresh=False):
|
||||
self.plan = copy.deepcopy(self.global_plan["default_plan"].plan)
|
||||
self.config: PlanConfig = copy.deepcopy(self.global_plan["default_plan"].config)
|
||||
for index, success in enumerate(condition):
|
||||
if success:
|
||||
self.plan, self.config = self.merge_plan(index, self.config, self.plan)
|
||||
self.plan_condition = condition
|
||||
if refresh:
|
||||
self.first_init = True
|
||||
error = self.init_and_validate(True)
|
||||
self.first_init = False
|
||||
if error:
|
||||
return error
|
||||
|
||||
def merge_plan(self, idx, ext_config, default_plan=None):
|
||||
if default_plan is None:
|
||||
default_plan = copy.deepcopy(self.global_plan["default_plan"].plan)
|
||||
plan = copy.deepcopy(self.global_plan["backup_plans"][idx])
|
||||
# 更新切换排班表
|
||||
for key, value in plan.plan.items():
|
||||
if key in default_plan:
|
||||
for idx, operator in enumerate(value):
|
||||
if operator.agent != "Current":
|
||||
default_plan[key][idx] = operator
|
||||
return default_plan, ext_config.merge_config(plan.config)
|
||||
|
||||
def generate_conditions(self, n):
|
||||
if n == 1:
|
||||
return [[True], [False]]
|
||||
else:
|
||||
prev_conditions = self.generate_conditions(n - 1)
|
||||
conditions = []
|
||||
for condition in prev_conditions:
|
||||
conditions.append(condition + [True])
|
||||
conditions.append(condition + [False])
|
||||
return conditions
|
||||
|
||||
def init_and_validate(self, update=False):
|
||||
self.groups = {}
|
||||
self.exhaust_agent = []
|
||||
self.exhaust_group = []
|
||||
self.workaholic_agent = []
|
||||
self.shadow_copy = copy.deepcopy(self.operators)
|
||||
self.operators = {}
|
||||
for room in self.plan.keys():
|
||||
for idx, data in enumerate(self.plan[room]):
|
||||
if data.agent not in list(agent_list.keys()) and data.agent != "Free":
|
||||
return f"干员名输入错误: 房间->{room}, 干员->{data.agent}"
|
||||
if data.agent in ["龙舌兰", "但书"]:
|
||||
return f"高效组不可用龙舌兰,但书 房间->{room}, 干员->{data.agent}"
|
||||
if data.agent == "菲亚梅塔" and idx == 1:
|
||||
return f"菲亚梅塔不能安排在2号位置 房间->{room}, 干员->{data.agent}"
|
||||
if data.agent == "菲亚梅塔" and not room.startswith("dorm"):
|
||||
return "菲亚梅塔必须安排在宿舍"
|
||||
if data.agent == "Free" and not room.startswith("dorm"):
|
||||
return f"Free只能安排在宿舍 房间->{room}, 干员->{data.agent}"
|
||||
if data.agent in self.operators and data.agent != "Free":
|
||||
return f"高效组干员不可重复 房间->{room},{self.operators[data.agent].room}, 干员->{data.agent}"
|
||||
self.add(
|
||||
Operator(
|
||||
data.agent,
|
||||
room,
|
||||
idx,
|
||||
data.group,
|
||||
data.replacement,
|
||||
"high",
|
||||
operator_type="high",
|
||||
)
|
||||
)
|
||||
missing_replacements = []
|
||||
for room in self.plan.keys():
|
||||
if room.startswith("dorm") and len(self.plan[room]) != 5:
|
||||
return f"宿舍 {room} 人数少于5人"
|
||||
for idx, data in enumerate(self.plan[room]):
|
||||
# 菲亚梅塔替换组做特例判断
|
||||
if "龙舌兰" in data.replacement and "但书" in data.replacement:
|
||||
return f"替换组不可同时安排龙舌兰和但书 房间->{room}, 干员->{data.agent}"
|
||||
if "菲亚梅塔" in data.replacement:
|
||||
return f"替换组不可安排菲亚梅塔 房间->{room}, 干员->{data.agent}"
|
||||
r_count = len(data.replacement)
|
||||
if "龙舌兰" in data.replacement or "但书" in data.replacement:
|
||||
r_count -= 1
|
||||
if r_count <= 0 and (
|
||||
(data.agent != "Free" and (not room.startswith("dorm")))
|
||||
or data.agent == "菲亚梅塔"
|
||||
):
|
||||
missing_replacements.append(data.agent)
|
||||
for _replacement in data.replacement:
|
||||
if (
|
||||
_replacement not in list(agent_list.keys())
|
||||
and data.agent != "Free"
|
||||
):
|
||||
return f"干员名输入错误: 房间->{room}, 干员->{_replacement}"
|
||||
if data.agent != "菲亚梅塔":
|
||||
# 普通替换
|
||||
if (
|
||||
_replacement in self.operators
|
||||
and self.operators[_replacement].is_high()
|
||||
):
|
||||
return f"替换组不可用高效组干员: 房间->{room}, 干员->{_replacement}"
|
||||
self.add(Operator(_replacement, ""))
|
||||
else:
|
||||
if _replacement not in self.operators:
|
||||
return f"菲亚梅塔替换不在高效组列: 房间->{room}, 干员->{_replacement}"
|
||||
if (
|
||||
_replacement in self.operators
|
||||
and not self.operators[_replacement].is_high()
|
||||
):
|
||||
return f"菲亚梅塔替换只能为高效组干员: 房间->{room}, 干员->{_replacement}"
|
||||
# 判定替换缺失
|
||||
if "菲亚梅塔" in missing_replacements:
|
||||
return "菲亚梅塔替换缺失"
|
||||
if len(missing_replacements):
|
||||
return f'以下干员替换组缺失:{",".join(missing_replacements)}'
|
||||
dorm_names = [k for k in self.plan.keys() if k.startswith("dorm")]
|
||||
dorm_names.sort(key=lambda d: d, reverse=False)
|
||||
added = []
|
||||
# 竖向遍历出效率高到低
|
||||
if not update:
|
||||
for dorm in dorm_names:
|
||||
free_found = False
|
||||
for _idx, _dorm in enumerate(self.plan[dorm]):
|
||||
if _dorm.agent == "Free" and _idx <= 1:
|
||||
if "波登可" not in [_agent.agent for _agent in self.plan[dorm]]:
|
||||
return "宿舍必须安排2个宿管"
|
||||
if _dorm.agent != "Free" and free_found:
|
||||
return "Free必须连续且安排在宿管后"
|
||||
if (
|
||||
_dorm.agent == "Free"
|
||||
and not free_found
|
||||
and (dorm + str(_idx)) not in added
|
||||
and len(added) < self.config.max_resting_count
|
||||
):
|
||||
self.dorm.append(Dormitory((dorm, _idx)))
|
||||
added.append(dorm + str(_idx))
|
||||
free_found = True
|
||||
continue
|
||||
if not free_found:
|
||||
return "宿舍必须安排至少一个Free"
|
||||
# VIP休息位用完后横向遍历
|
||||
for dorm in dorm_names:
|
||||
for _idx, _dorm in enumerate(self.plan[dorm]):
|
||||
if _dorm.agent == "Free" and (dorm + str(_idx)) not in added:
|
||||
self.dorm.append(Dormitory((dorm, _idx)))
|
||||
added.append(dorm + str(_idx))
|
||||
else:
|
||||
for key, value in self.shadow_copy.items():
|
||||
if key not in self.operators:
|
||||
self.add(Operator(key, ""))
|
||||
if len(self.dorm) < self.config.max_resting_count:
|
||||
return f"宿舍Free总数 {len(self.dorm)}小于最大分组数 {self.config.max_resting_count}"
|
||||
# 跑单
|
||||
for x, y in self.plan.items():
|
||||
if not x.startswith("room"):
|
||||
continue
|
||||
if any(
|
||||
("但书" in obj.replacement or "龙舌兰" in obj.replacement) for obj in y
|
||||
):
|
||||
self.run_order_rooms[x] = {}
|
||||
# 判定分组排班可能性
|
||||
current_high = self.config.max_resting_count
|
||||
current_low = len(self.dorm) - self.config.max_resting_count
|
||||
for key in self.groups:
|
||||
high_count = 0
|
||||
low_count = 0
|
||||
_replacement = []
|
||||
for name in self.groups[key]:
|
||||
_candidate = next(
|
||||
(
|
||||
r
|
||||
for r in self.operators[name].replacement
|
||||
if r not in _replacement and r not in ["龙舌兰", "但书"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
if _candidate is None:
|
||||
return f"{key} 分组无法排班,替换组数量不够"
|
||||
else:
|
||||
_replacement.append(_candidate)
|
||||
if self.operators[name].workaholic:
|
||||
continue
|
||||
if self.operators[name].resting_priority == "high":
|
||||
high_count += 1
|
||||
else:
|
||||
low_count += 1
|
||||
if high_count > current_high or low_count > current_low:
|
||||
return f"{key} 分组无法排班,宿舍可用高优先{current_high},低优先{current_low}->分组需要高优先{high_count},低优先{low_count}"
|
||||
# 设定令夕模式的心情阈值
|
||||
self.init_mood_limit()
|
||||
for name in self.workaholic_agent:
|
||||
if name not in self.config.free_blacklist:
|
||||
self.config.free_blacklist.append(name)
|
||||
logger.info("宿舍黑名单:" + str(self.config.free_blacklist))
|
||||
|
||||
def set_mood_limit(self, name, upper_limit=24, lower_limit=0):
|
||||
if name in self.operators:
|
||||
self.operators[name].upper_limit = upper_limit
|
||||
self.operators[name].lower_limit = lower_limit
|
||||
logger.info(f"自动设置{name}心情下限为{lower_limit},上限为{upper_limit}")
|
||||
|
||||
def init_mood_limit(self):
|
||||
# 设置心情阈值 for 夕,令,
|
||||
if self.config.ling_xi == 1:
|
||||
self.set_mood_limit("令", upper_limit=12)
|
||||
self.set_mood_limit("夕", lower_limit=12)
|
||||
elif self.config.ling_xi == 2:
|
||||
self.set_mood_limit("夕", upper_limit=12)
|
||||
self.set_mood_limit("令", lower_limit=12)
|
||||
elif self.config.ling_xi == 0:
|
||||
self.set_mood_limit("夕")
|
||||
self.set_mood_limit("令")
|
||||
# 设置同组心情阈值
|
||||
finished = []
|
||||
for name in ["夕", "令"]:
|
||||
if (
|
||||
name in self.operators
|
||||
and self.operators[name].group != ""
|
||||
and self.operators[name].group not in finished
|
||||
):
|
||||
for group_name in self.groups[self.operators[name].group]:
|
||||
if group_name not in ["夕", "令"]:
|
||||
if self.config.ling_xi in [1, 2]:
|
||||
self.set_mood_limit(group_name, lower_limit=12)
|
||||
elif self.config.ling_xi == 0:
|
||||
self.set_mood_limit(group_name, lower_limit=0)
|
||||
finished.append(self.operators[name].group)
|
||||
|
||||
# 设置铅踝心情阈值
|
||||
# 三种情况:
|
||||
# 1. 铅踝不是主力:不管
|
||||
# 2. 铅踝是红云组主力,设置心情上限 12、下限 8,效率 37%
|
||||
# 3. 铅踝是普通主力:设置心情下限 20,效率 30%
|
||||
TOTTER = "铅踝"
|
||||
VERMEIL = "红云"
|
||||
if TOTTER in self.operators and self.operators[TOTTER].operator_type == "high":
|
||||
if (
|
||||
VERMEIL in self.operators
|
||||
and self.operators[VERMEIL].operator_type == "high"
|
||||
and self.operators[VERMEIL].room == self.operators[TOTTER].room
|
||||
):
|
||||
self.set_mood_limit(TOTTER, upper_limit=12, lower_limit=8)
|
||||
else:
|
||||
self.set_mood_limit(TOTTER, upper_limit=24, lower_limit=20)
|
||||
|
||||
def evaluate_expression(self, expression):
|
||||
try:
|
||||
result = Expr(expression, self.eval_model).eval({"op_data": self})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception(f"Error evaluating expression: {e}")
|
||||
return None
|
||||
|
||||
def get_current_room(self, room, bypass=False, current_index=None):
|
||||
room_data = {
|
||||
v.current_index: v
|
||||
for k, v in self.operators.items()
|
||||
if v.current_room == room
|
||||
}
|
||||
res = [obj.agent for obj in self.plan[room]]
|
||||
not_found = False
|
||||
for idx, op in enumerate(res):
|
||||
if idx in room_data:
|
||||
res[idx] = room_data[idx].name
|
||||
else:
|
||||
res[idx] = ""
|
||||
if current_index is not None and idx not in current_index:
|
||||
continue
|
||||
not_found = True
|
||||
if not_found and not bypass:
|
||||
return None
|
||||
else:
|
||||
return res
|
||||
|
||||
def predict_fia(self, operators, fia_mood, hours=240):
|
||||
recover_hours = (24 - fia_mood) / 2
|
||||
for agent in operators:
|
||||
agent.mood -= agent.depletion_rate * recover_hours
|
||||
if agent.mood < 0.0:
|
||||
return False
|
||||
if recover_hours >= hours or 0 < recover_hours < 1:
|
||||
return True
|
||||
operators.sort(
|
||||
key=lambda x: (x.mood - x.lower_limit) / (x.upper_limit - x.lower_limit),
|
||||
reverse=False,
|
||||
)
|
||||
fia_mood = operators[0].mood
|
||||
operators[0].mood = 24
|
||||
return self.predict_fia(operators, fia_mood, hours - recover_hours)
|
||||
|
||||
def reset_dorm_time(self):
|
||||
for name in self.operators.keys():
|
||||
agent = self.operators[name]
|
||||
if agent.room.startswith("dorm"):
|
||||
agent.time_stamp = None
|
||||
|
||||
@save_action_to_sqlite_decorator
|
||||
def update_detail(self, name, mood, current_room, current_index, update_time=False):
|
||||
agent = self.operators[name]
|
||||
if update_time:
|
||||
if agent.time_stamp is not None and agent.mood > mood:
|
||||
agent.depletion_rate = (
|
||||
(agent.mood - mood)
|
||||
* 3600
|
||||
/ ((datetime.now() - agent.time_stamp).total_seconds())
|
||||
)
|
||||
agent.time_stamp = datetime.now()
|
||||
# 如果移出宿舍,则清除对应宿舍数据 且重新记录高效组心情(如果有备用班,则跳过高效组判定)
|
||||
if (
|
||||
agent.current_room.startswith("dorm")
|
||||
and not current_room.startswith("dorm")
|
||||
and (agent.is_high() or self.backup_plans)
|
||||
):
|
||||
self.refresh_dorm_time(
|
||||
agent.current_room, agent.current_index, {"agent": ""}
|
||||
)
|
||||
if update_time:
|
||||
self.time_stamp = datetime.now()
|
||||
else:
|
||||
self.time_stamp = None
|
||||
agent.depletion_rate = 0
|
||||
if (
|
||||
self.get_dorm_by_name(name)[0] is not None
|
||||
and not current_room.startswith("dorm")
|
||||
and (agent.is_high() or self.backup_plans)
|
||||
):
|
||||
_dorm = self.get_dorm_by_name(name)[1]
|
||||
_dorm.name = ""
|
||||
_dorm.time = None
|
||||
agent.current_room = current_room
|
||||
agent.current_index = current_index
|
||||
agent.mood = mood
|
||||
# 如果是高效组且没有记录时间,则返还index
|
||||
if agent.current_room.startswith("dorm") and (
|
||||
agent.is_high() or self.backup_plans
|
||||
):
|
||||
for dorm in self.dorm:
|
||||
if (
|
||||
dorm.position[0] == current_room
|
||||
and dorm.position[1] == current_index
|
||||
and dorm.time is None
|
||||
):
|
||||
return current_index
|
||||
if agent.name == "菲亚梅塔" and (
|
||||
self.operators["菲亚梅塔"].time_stamp is None
|
||||
or self.operators["菲亚梅塔"].time_stamp < datetime.now()
|
||||
):
|
||||
return current_index
|
||||
|
||||
def refresh_dorm_time(self, room, index, agent):
|
||||
for idx, dorm in enumerate(self.dorm):
|
||||
# Filter out resting priority low
|
||||
# if idx >= self.config.max_resting_count:
|
||||
# break
|
||||
if dorm.position[0] == room and dorm.position[1] == index:
|
||||
# 如果人为高效组,则记录时间
|
||||
_name = agent["agent"]
|
||||
if _name in self.operators.keys() and (
|
||||
self.operators[_name].is_high() or self.config.free_room
|
||||
):
|
||||
dorm.name = _name
|
||||
_agent = self.operators[_name]
|
||||
# 如果干员有心情上限,则按比例修改休息时间
|
||||
if _agent.mood != 24:
|
||||
sec_remaining = (
|
||||
(_agent.upper_limit - _agent.mood)
|
||||
* ((agent["time"] - _agent.time_stamp).total_seconds())
|
||||
/ (24 - _agent.mood)
|
||||
)
|
||||
dorm.time = _agent.time_stamp + timedelta(seconds=sec_remaining)
|
||||
else:
|
||||
dorm.time = agent["time"]
|
||||
elif _name in list(agent_list.keys()):
|
||||
dorm.name = _name
|
||||
dorm.time = agent["time"]
|
||||
break
|
||||
|
||||
def correct_dorm(self):
|
||||
for idx, dorm in enumerate(self.dorm):
|
||||
if dorm.name != "" and dorm.name in self.operators.keys():
|
||||
op = self.operators[dorm.name]
|
||||
if not (
|
||||
dorm.position[0] == op.current_room
|
||||
and dorm.position[1] == op.current_index
|
||||
):
|
||||
self.dorm[idx].name = ""
|
||||
self.dorm[idx].time = None
|
||||
else:
|
||||
if (
|
||||
self.dorm[idx].time is not None
|
||||
and self.dorm[idx].time < datetime.now()
|
||||
):
|
||||
op.mood = op.upper_limit
|
||||
op.time_stamp = self.dorm[idx].time
|
||||
logger.debug(
|
||||
f"检测到{op.name}心情恢复满,设置心情至{op.upper_limit}"
|
||||
)
|
||||
|
||||
def get_train_support(self):
|
||||
for name in self.operators.keys():
|
||||
agent = self.operators[name]
|
||||
if agent.current_room == "train" and agent.current_index == 0:
|
||||
return agent.name
|
||||
return None
|
||||
|
||||
def get_refresh_index(self, room, plan):
|
||||
ret = []
|
||||
if room.startswith("dorm") and self.config.free_room:
|
||||
return [i for i, x in enumerate(self.plan[room]) if x == "Free"]
|
||||
for idx, dorm in enumerate(self.dorm):
|
||||
# Filter out resting priority low
|
||||
if idx >= self.config.max_resting_count:
|
||||
if not self.config.free_room:
|
||||
break
|
||||
if dorm.position[0] == room:
|
||||
for i, _name in enumerate(plan):
|
||||
if _name not in self.operators.keys():
|
||||
self.add(Operator(_name, ""))
|
||||
if not self.config.free_room:
|
||||
if self.operators[_name].is_high() and not self.operators[
|
||||
_name
|
||||
].room.startswith("dorm"):
|
||||
ret.append(i)
|
||||
elif not self.operators[_name].room.startswith("dorm"):
|
||||
ret.append(i)
|
||||
break
|
||||
return ret
|
||||
|
||||
def get_dorm_by_name(self, name):
|
||||
for idx, dorm in enumerate(self.dorm):
|
||||
if dorm.name == name:
|
||||
return idx, dorm
|
||||
return None, None
|
||||
|
||||
def add(self, operator):
|
||||
if operator.name not in list(agent_list.keys()):
|
||||
return
|
||||
if self.config.is_resting_priority(operator.name):
|
||||
operator.resting_priority = "low"
|
||||
operator.exhaust_require = self.config.is_exhaust_require(operator.name)
|
||||
operator.rest_in_full = self.config.is_rest_in_full(operator.name)
|
||||
operator.workaholic = self.config.is_workaholic(operator.name)
|
||||
operator.refresh_order_room = self.config.is_refresh_trading(operator.name)
|
||||
if operator.name in agent_arrange_order:
|
||||
operator.arrange_order = agent_arrange_order[operator.name]
|
||||
# 复制基建数据
|
||||
if operator.name in self.shadow_copy:
|
||||
exist = self.shadow_copy[operator.name]
|
||||
operator.mood = exist.mood
|
||||
operator.time_stamp = exist.time_stamp
|
||||
operator.depletion_rate = exist.depletion_rate
|
||||
operator.current_room = exist.current_room
|
||||
operator.current_index = exist.current_index
|
||||
self.operators[operator.name] = operator
|
||||
# 需要用尽心情干员逻辑
|
||||
if (
|
||||
operator.exhaust_require or operator.group in self.exhaust_group
|
||||
) and operator.name not in self.exhaust_agent:
|
||||
self.exhaust_agent.append(operator.name)
|
||||
if operator.group != "":
|
||||
self.exhaust_group.append(operator.group)
|
||||
# 干员分组逻辑
|
||||
if operator.group != "":
|
||||
if operator.group not in self.groups.keys():
|
||||
self.groups[operator.group] = [operator.name]
|
||||
else:
|
||||
self.groups[operator.group].append(operator.name)
|
||||
if operator.workaholic and operator.name not in self.workaholic_agent:
|
||||
self.workaholic_agent.append(operator.name)
|
||||
|
||||
def available_free(self, free_type="high"):
|
||||
ret = 0
|
||||
freeName = []
|
||||
if free_type == "high":
|
||||
idx = 0
|
||||
for dorm in self.dorm:
|
||||
if dorm.name == "" or (
|
||||
dorm.name in self.operators.keys()
|
||||
and not self.operators[dorm.name].is_high()
|
||||
):
|
||||
ret += 1
|
||||
elif dorm.time is not None and dorm.time < datetime.now():
|
||||
logger.info(f"检测到房间休息完毕,释放{dorm.name}宿舍位")
|
||||
freeName.append(dorm.name)
|
||||
ret += 1
|
||||
if idx == self.config.max_resting_count - 1:
|
||||
break
|
||||
else:
|
||||
idx += 1
|
||||
else:
|
||||
idx = self.config.max_resting_count
|
||||
for i in range(idx, len(self.dorm)):
|
||||
dorm = self.dorm[i]
|
||||
# 释放满休息位
|
||||
# TODO 高效组且低优先可以相互替换
|
||||
if dorm.name == "" or (
|
||||
dorm.name in self.operators.keys()
|
||||
and not self.operators[dorm.name].is_high()
|
||||
):
|
||||
ret += 1
|
||||
elif dorm.time is not None and dorm.time < datetime.now():
|
||||
logger.info(f"检测到房间休息完毕,释放{dorm.name}宿舍位")
|
||||
freeName.append(dorm.name)
|
||||
ret += 1
|
||||
if len(freeName) > 0:
|
||||
for name in freeName:
|
||||
if name in list(agent_list.keys()):
|
||||
self.operators[name].mood = self.operators[name].upper_limit
|
||||
self.operators[name].depletion_rate = 0
|
||||
self.operators[name].time_stamp = datetime.now()
|
||||
return ret
|
||||
|
||||
def assign_dorm(self, name):
|
||||
is_high = self.operators[name].resting_priority == "high"
|
||||
if is_high:
|
||||
_room = next(
|
||||
obj
|
||||
for obj in self.dorm
|
||||
if obj.name not in self.operators.keys()
|
||||
or not self.operators[obj.name].is_high()
|
||||
)
|
||||
else:
|
||||
_room = None
|
||||
for i in range(self.config.max_resting_count, len(self.dorm)):
|
||||
_name = self.dorm[i].name
|
||||
if _name == "" or not self.operators[_name].is_high():
|
||||
_room = self.dorm[i]
|
||||
break
|
||||
_room.name = name
|
||||
return _room
|
||||
|
||||
def get_current_operator(self, room, index):
|
||||
for key, value in self.operators.items():
|
||||
if value.current_room == room and value.current_index == index:
|
||||
return value
|
||||
return None
|
||||
|
||||
def print(self):
|
||||
ret = "{"
|
||||
op = []
|
||||
dorm = []
|
||||
for k, v in self.operators.items():
|
||||
op.append("'" + k + "': " + str(vars(v)))
|
||||
ret += "'operators': {" + ",".join(op) + "},"
|
||||
for v in self.dorm:
|
||||
dorm.append(str(vars(v)))
|
||||
ret += "'dorms': [" + ",".join(dorm) + "]}"
|
||||
return ret
|
||||
|
||||
|
||||
class Dormitory:
|
||||
def __init__(self, position, name="", time=None):
|
||||
self.position = position
|
||||
self.name = name
|
||||
self.time = time
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"Dormitory(position={self.position},name='{self.name}',time='{self.time}')"
|
||||
)
|
||||
|
||||
|
||||
class Operator:
|
||||
time_stamp = None
|
||||
depletion_rate = 0
|
||||
workaholic = False
|
||||
arrange_order = [2, "false"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
room,
|
||||
index=-1,
|
||||
group="",
|
||||
replacement=[],
|
||||
resting_priority="low",
|
||||
current_room="",
|
||||
exhaust_require=False,
|
||||
mood=24,
|
||||
upper_limit=24,
|
||||
rest_in_full=False,
|
||||
current_index=-1,
|
||||
lower_limit=0,
|
||||
operator_type="low",
|
||||
depletion_rate=0,
|
||||
time_stamp=None,
|
||||
refresh_order_room=None,
|
||||
):
|
||||
if refresh_order_room is not None:
|
||||
self.refresh_order_room = refresh_order_room
|
||||
self.refresh_order_room = [False, []]
|
||||
self.name = name
|
||||
self.room = room
|
||||
self.operator_type = operator_type
|
||||
self.index = index
|
||||
self.group = group
|
||||
self.replacement = replacement
|
||||
self.resting_priority = resting_priority
|
||||
self._current_room = None
|
||||
self.current_room = current_room
|
||||
self.exhaust_require = exhaust_require
|
||||
self.upper_limit = upper_limit
|
||||
self.rest_in_full = rest_in_full
|
||||
self.mood = mood
|
||||
self.current_index = current_index
|
||||
self.lower_limit = lower_limit
|
||||
self.depletion_rate = depletion_rate
|
||||
self.time_stamp = time_stamp
|
||||
|
||||
@property
|
||||
def current_room(self):
|
||||
return self._current_room
|
||||
|
||||
@current_room.setter
|
||||
def current_room(self, value):
|
||||
if self._current_room != value:
|
||||
self._current_room = value
|
||||
if Operators.current_room_changed_callback and self.refresh_order_room[0]:
|
||||
Operators.current_room_changed_callback(self)
|
||||
|
||||
def is_high(self):
|
||||
return self.operator_type == "high"
|
||||
|
||||
def is_resting(self):
|
||||
return self.current_room.startswith("dorm")
|
||||
|
||||
def is_working(self):
|
||||
return self.current_room in base_room_list and not self.is_resting()
|
||||
|
||||
def need_to_refresh(self, h=2, r=""):
|
||||
# 是否需要读取心情
|
||||
if (
|
||||
self.time_stamp is None
|
||||
or (
|
||||
self.time_stamp is not None
|
||||
and self.time_stamp + timedelta(hours=h) < datetime.now()
|
||||
)
|
||||
or (r.startswith("dorm") and not self.room.startswith("dorm"))
|
||||
):
|
||||
return True
|
||||
|
||||
def not_valid(self):
|
||||
if self.room == "train":
|
||||
return False
|
||||
if self.operator_type == "high":
|
||||
if self.workaholic:
|
||||
return (
|
||||
self.current_room != self.room or self.index != self.current_index
|
||||
)
|
||||
if not self.room.startswith("dorm") and self.current_room.startswith(
|
||||
"dorm"
|
||||
):
|
||||
if self.mood == -1 or self.mood == 24:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
return (
|
||||
self.need_to_refresh(2.5)
|
||||
or self.current_room != self.room
|
||||
or self.index != self.current_index
|
||||
)
|
||||
return False
|
||||
|
||||
def current_mood(self):
|
||||
predict = self.mood
|
||||
if self.time_stamp is not None:
|
||||
predict = (
|
||||
self.mood
|
||||
- self.depletion_rate
|
||||
* (datetime.now() - self.time_stamp).total_seconds()
|
||||
/ 3600
|
||||
)
|
||||
if 0 <= predict <= 24:
|
||||
return predict
|
||||
else:
|
||||
return self.mood
|
||||
|
||||
def __repr__(self):
|
||||
return f"Operator(name='{self.name}', room='{self.room}', index={self.index}, group='{self.group}', replacement={self.replacement}, resting_priority='{self.resting_priority}', current_room='{self.current_room}',exhaust_require={self.exhaust_require},mood={self.mood}, upper_limit={self.upper_limit}, rest_in_full={self.rest_in_full}, current_index={self.current_index}, lower_limit={self.lower_limit}, operator_type='{self.operator_type}',depletion_rate={self.depletion_rate},time_stamp='{self.time_stamp}',refresh_order_room = {self.refresh_order_room})"
|
43
mower/utils/path.py
Normal file
43
mower/utils/path.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
|
||||
appname = "mower"
|
||||
appauthor = "ArkMower"
|
||||
# global_space:多开时global_space为多开数据目录
|
||||
|
||||
global_space = None
|
||||
|
||||
# install_dir:源码目录
|
||||
|
||||
_install_dir = Path(os.getcwd()).resolve()
|
||||
|
||||
|
||||
def get_path(path: str) -> Path:
|
||||
"""
|
||||
使用 '@xxx/' 来表示一些特别的目录
|
||||
@app: 多开数据目录, 例如 get_path('@app/log/runtime.log')
|
||||
@install: 源码目录
|
||||
"""
|
||||
global global_space
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
if isinstance(path, str) and path.startswith("@"):
|
||||
index = path.find("/")
|
||||
index = index if index != -1 else len(path)
|
||||
special_dir_name = path[1:index]
|
||||
relative_path = path[index:].strip("/")
|
||||
if special_dir_name == "app":
|
||||
return (
|
||||
Path(_install_dir) / relative_path
|
||||
if global_space is None
|
||||
else Path(global_space) / relative_path
|
||||
)
|
||||
elif special_dir_name == "install":
|
||||
return Path(_install_dir) / relative_path
|
||||
else:
|
||||
raise ValueError(
|
||||
"{}: {} 不是一个有效的特殊目录别名".format(path, special_dir_name)
|
||||
)
|
||||
else:
|
||||
return Path(path)
|
||||
# raise ValueError("{} 路径必须以 '@xxx' 开头".format(path))
|
161
mower/utils/plan.py
Normal file
161
mower/utils/plan.py
Normal file
|
@ -0,0 +1,161 @@
|
|||
import copy
|
||||
from enum import Enum
|
||||
from typing import Optional, Self
|
||||
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.logic_expression import LogicExpression
|
||||
|
||||
|
||||
class PlanTriggerTiming(Enum):
|
||||
"副表触发时机"
|
||||
|
||||
BEGINNING = 0
|
||||
"任务开始"
|
||||
BEFORE_PLANNING = 300
|
||||
"下班结束"
|
||||
AFTER_PLANNING = 600
|
||||
"上班结束"
|
||||
END = 999
|
||||
"任务结束"
|
||||
|
||||
|
||||
def to_list(str_data: str) -> list[str]:
|
||||
lst = str_data.replace(",", ",").split(",")
|
||||
return [x.strip() for x in lst]
|
||||
|
||||
|
||||
class PlanConfig:
|
||||
def __init__(
|
||||
self,
|
||||
rest_in_full: str,
|
||||
exhaust_require: str,
|
||||
resting_priority: str,
|
||||
ling_xi: int = 0,
|
||||
workaholic: str = "",
|
||||
max_resting_count: int = 4,
|
||||
free_blacklist: str = "",
|
||||
resting_threshold: float = 0.5,
|
||||
refresh_trading_config: str = "",
|
||||
free_room: bool = False,
|
||||
):
|
||||
"""排班的设置
|
||||
|
||||
Args:
|
||||
rest_in_full: 回满
|
||||
exhaust_require: 耗尽
|
||||
resting_priority: 低优先级
|
||||
ling_xi: 令夕模式
|
||||
workaholic: 0心情工作
|
||||
max_resting_count: 最大组人数
|
||||
free_blacklist: 宿舍黑名单
|
||||
resting_threshold: 心情阈值
|
||||
refresh_trading_config: 跑单时间刷新干员
|
||||
free_room: 宿舍不养闲人模式
|
||||
"""
|
||||
self.rest_in_full = to_list(rest_in_full)
|
||||
self.exhaust_require = to_list(exhaust_require)
|
||||
self.workaholic = to_list(workaholic)
|
||||
self.resting_priority = to_list(resting_priority)
|
||||
self.max_resting_count = max_resting_count
|
||||
self.free_blacklist = to_list(free_blacklist)
|
||||
# 0 为均衡模式
|
||||
# 1 为感知信息模式
|
||||
# 2 为人间烟火模式
|
||||
self.ling_xi = ling_xi
|
||||
self.resting_threshold = resting_threshold
|
||||
self.free_room = free_room
|
||||
# 格式为 干员名字+ 括弧 +指定房间(逗号分隔)
|
||||
# 不指定房间则默认全跑单站
|
||||
# example: 阿米娅,夕,令
|
||||
# 夕(room_3_1,room_1_3),令(room_3_1)
|
||||
self.refresh_trading_config = to_list(refresh_trading_config)
|
||||
|
||||
def is_rest_in_full(self, agent_name) -> bool:
|
||||
return agent_name in self.rest_in_full
|
||||
|
||||
def is_exhaust_require(self, agent_name) -> bool:
|
||||
return agent_name in self.exhaust_require
|
||||
|
||||
def is_workaholic(self, agent_name) -> bool:
|
||||
return agent_name in self.workaholic
|
||||
|
||||
def is_resting_priority(self, agent_name) -> bool:
|
||||
return agent_name in self.resting_priority
|
||||
|
||||
def is_free_blacklist(self, agent_name) -> bool:
|
||||
return agent_name in self.free_blacklist
|
||||
|
||||
def is_refresh_trading(self, agent_name) -> list[bool, list[str]]:
|
||||
match = next(
|
||||
(e for e in self.refresh_trading_config if agent_name in e.lower()),
|
||||
None,
|
||||
)
|
||||
if match is not None:
|
||||
if match.replace(agent_name, "") != "":
|
||||
return [True, match.replace(agent_name, "").split(",")]
|
||||
else:
|
||||
return [True, []]
|
||||
else:
|
||||
return [False, []]
|
||||
|
||||
def merge_config(self, target: Self) -> Self:
|
||||
n = copy.deepcopy(self)
|
||||
for p in [
|
||||
"rest_in_full",
|
||||
"exhaust_require",
|
||||
"workaholic",
|
||||
"resting_priority",
|
||||
"free_blacklist",
|
||||
"refresh_trading_config",
|
||||
]:
|
||||
p_dict = set(getattr(n, p))
|
||||
target_p = set(getattr(target, p))
|
||||
setattr(n, p, list(p_dict.union(target_p)))
|
||||
return n
|
||||
|
||||
|
||||
class Room:
|
||||
def __init__(self, agent: str, group: str, replacement: list[str]):
|
||||
"""房间
|
||||
|
||||
Args:
|
||||
agent: 主力干员
|
||||
group: 组
|
||||
replacement: 替换组
|
||||
"""
|
||||
self.agent = agent
|
||||
self.group = group
|
||||
self.replacement = replacement
|
||||
|
||||
|
||||
class Plan:
|
||||
def __init__(
|
||||
self,
|
||||
plan: dict[str, Room],
|
||||
config: PlanConfig,
|
||||
trigger: Optional[LogicExpression] = None,
|
||||
task: Optional[dict[str, list[str]]] = None,
|
||||
trigger_timing: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
plan: 基建计划 or 触发备用plan 的排班表,只需要填和默认不一样的部分
|
||||
config: 基建计划相关配置,必须填写全部配置
|
||||
trigger: 触发备用plan 的条件(必填)就是每次最多只有一个备用plan触发
|
||||
task: 触发备用plan 的时间生成的任务(选填)
|
||||
trigger_timing: 触发时机
|
||||
"""
|
||||
self.plan = plan
|
||||
self.config = config
|
||||
self.trigger = trigger
|
||||
self.task = task
|
||||
self.trigger_timing = self.set_timing_enum(trigger_timing)
|
||||
|
||||
@staticmethod
|
||||
def set_timing_enum(value: str) -> PlanTriggerTiming:
|
||||
"将字符串转换为副表触发时机"
|
||||
try:
|
||||
return PlanTriggerTiming[value.upper()]
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return PlanTriggerTiming.AFTER_PLANNING
|
78
mower/utils/qrcode.py
Normal file
78
mower/utils/qrcode.py
Normal file
|
@ -0,0 +1,78 @@
|
|||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from zlib import compress, decompress
|
||||
|
||||
from base45 import b45decode, b45encode
|
||||
from PIL import Image, ImageChops, ImageDraw
|
||||
from pyzbar import pyzbar
|
||||
from qrcode.constants import ERROR_CORRECT_L
|
||||
from qrcode.main import QRCode
|
||||
|
||||
QRCODE_SIZE = 215
|
||||
GAP_SIZE = 16
|
||||
BLACK = (0, 0, 0)
|
||||
WHITE = (255, 255, 255)
|
||||
TOP = 40
|
||||
BOTTOM = 995
|
||||
LEFT = 40
|
||||
|
||||
|
||||
def encode(data: str, n: int = 16, theme: str = "light") -> List[Image.Image]:
|
||||
data = b45encode(compress(data.encode("utf-8"), level=9))
|
||||
length = len(data)
|
||||
split: List[bytes] = []
|
||||
for i in range(n):
|
||||
start = length // n * i
|
||||
end = length if i == n - 1 else length // n * (i + 1)
|
||||
split.append(data[start:end])
|
||||
result: List[Image.Image] = []
|
||||
qr = QRCode(error_correction=ERROR_CORRECT_L)
|
||||
fg, bg = (BLACK, WHITE) if theme == "light" else (WHITE, BLACK)
|
||||
for i in split:
|
||||
qr.add_data(i)
|
||||
img: Image.Image = qr.make_image(fill_color=fg, back_color=bg)
|
||||
result.append(trim(img.get_image()))
|
||||
qr.clear()
|
||||
return result
|
||||
|
||||
|
||||
def trim(img: Image.Image) -> Image.Image:
|
||||
bg = Image.new(img.mode, img.size, img.getpixel((0, 0)))
|
||||
diff = ImageChops.difference(img, bg)
|
||||
img = img.crop(diff.getbbox())
|
||||
img = img.resize((QRCODE_SIZE, QRCODE_SIZE))
|
||||
return img
|
||||
|
||||
|
||||
def export(plan: Dict, img: Image.Image, theme: str = "light") -> Image.Image:
|
||||
qrcode_list = encode(json.dumps(plan), theme=theme)
|
||||
for idx, i in enumerate(qrcode_list[:7]):
|
||||
img.paste(i, (LEFT + idx * (GAP_SIZE + QRCODE_SIZE), TOP))
|
||||
for idx, i in enumerate(qrcode_list[7:14]):
|
||||
img.paste(i, (LEFT + idx * (GAP_SIZE + QRCODE_SIZE), BOTTOM))
|
||||
for idx, i in enumerate(qrcode_list[14:]):
|
||||
img.paste(i, (2520 + idx * (GAP_SIZE + QRCODE_SIZE), BOTTOM))
|
||||
img = img.convert("RGB")
|
||||
return img
|
||||
|
||||
|
||||
def decode(img: Image.Image) -> Optional[Dict]:
|
||||
img = img.convert("RGB")
|
||||
if img.getpixel((0, 0)) == BLACK:
|
||||
img = ImageChops.invert(img)
|
||||
result = []
|
||||
while len(data := pyzbar.decode(img)):
|
||||
img1 = ImageDraw.Draw(img)
|
||||
for d in data:
|
||||
if d.quality > 1:
|
||||
continue
|
||||
left = d.rect.left - 2
|
||||
top = d.rect.top - 2
|
||||
right = left + d.rect.width + 5
|
||||
bottom = top + d.rect.height + 5
|
||||
scope = ((left, top), (right, bottom))
|
||||
img1.rectangle(scope, fill=WHITE)
|
||||
result.append(d)
|
||||
result.sort(key=lambda i: (i.rect.top * 2 > img.size[1], i.rect.left))
|
||||
result = b45decode(b"".join([i.data for i in result]))
|
||||
return json.loads(decompress(result).decode("utf-8"))
|
9
mower/utils/rapidocr.py
Normal file
9
mower/utils/rapidocr.py
Normal file
|
@ -0,0 +1,9 @@
|
|||
engine = None
|
||||
|
||||
|
||||
def initialize_ocr(score=0.3):
|
||||
global engine
|
||||
if not engine:
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
engine = RapidOCR(text_score=score)
|
773
mower/utils/recognize/__init__.py
Normal file
773
mower/utils/recognize/__init__.py
Normal file
|
@ -0,0 +1,773 @@
|
|||
import time
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from skimage.metrics import structural_similarity
|
||||
|
||||
from mower import __rootdir__ as __rootdir__
|
||||
from mower.utils import config
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.csleep import MowerExit
|
||||
from mower.utils.image import bytes2img, cmatch, cropimg, loadres, thres2
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.matcher import Matcher
|
||||
from mower.utils.scene import Scene, SceneComment
|
||||
from mower.utils.vector import va
|
||||
|
||||
from .data import color, template_matching, template_matching_score
|
||||
|
||||
|
||||
class RecognizeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Recognizer:
|
||||
def __init__(self, screencap: Optional[bytes] = None) -> None:
|
||||
self.w = 1920
|
||||
self.h = 1080
|
||||
self.clear()
|
||||
self.start(screencap)
|
||||
self.loading_time = 0
|
||||
self.LOADING_TIME_LIMIT = 5
|
||||
|
||||
def clear(self):
|
||||
self._img = None
|
||||
self._gray = None
|
||||
self._hsv = None
|
||||
self._matcher = None
|
||||
self.scene = Scene.UNDEFINED
|
||||
|
||||
@property
|
||||
def img(self):
|
||||
if self._img is None:
|
||||
self.start()
|
||||
return self._img
|
||||
|
||||
@property
|
||||
def gray(self):
|
||||
if self._gray is None:
|
||||
self._gray = cv2.cvtColor(self.img, cv2.COLOR_RGB2GRAY)
|
||||
return self._gray
|
||||
|
||||
@property
|
||||
def hsv(self):
|
||||
if self._hsv is None:
|
||||
self._hsv = cv2.cvtColor(self.img, cv2.COLOR_RGB2HSV)
|
||||
return self._hsv
|
||||
|
||||
@property
|
||||
def matcher(self):
|
||||
if self._matcher is None:
|
||||
self._matcher = Matcher(self.gray)
|
||||
return self._matcher
|
||||
|
||||
def start(self, screencap: Optional[bytes] = None) -> None:
|
||||
"""init with screencap"""
|
||||
retry_times = config.MAX_RETRYTIME
|
||||
while retry_times > 0:
|
||||
try:
|
||||
if screencap is not None:
|
||||
self._img = bytes2img(screencap)
|
||||
else:
|
||||
self._img = config.device.screencap()
|
||||
return
|
||||
except cv2.error as e:
|
||||
logger.warning(e)
|
||||
retry_times -= 1
|
||||
time.sleep(1)
|
||||
continue
|
||||
raise RuntimeError("init Recognizer failed")
|
||||
|
||||
def update(self) -> None:
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
self.clear()
|
||||
|
||||
def color(self, x: int, y: int) -> tp.Pixel:
|
||||
"""get the color of the pixel"""
|
||||
return self.img[y][x]
|
||||
|
||||
def detect_index_scene(self) -> bool:
|
||||
res = loadres("index_nav", True)
|
||||
h, w = res.shape
|
||||
img = cropimg(self.gray, ((25, 17), (25 + w, 17 + h)))
|
||||
img = thres2(img, 240)
|
||||
result = cv2.matchTemplate(img, res, cv2.TM_SQDIFF_NORMED)
|
||||
result = result[0][0]
|
||||
if result < 0.1:
|
||||
logger.debug(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
def check_current_focus(self):
|
||||
if config.device.check_current_focus():
|
||||
self.update()
|
||||
|
||||
def check_loading_time(self):
|
||||
if self.scene == Scene.CONNECTING:
|
||||
self.loading_time += 1
|
||||
if self.loading_time > 1:
|
||||
logger.debug(f"检测到连续等待{self.loading_time}次")
|
||||
else:
|
||||
self.loading_time = 0
|
||||
if self.loading_time > self.LOADING_TIME_LIMIT:
|
||||
logger.info(f"检测到连续等待{self.loading_time}次")
|
||||
config.device.exit()
|
||||
time.sleep(3)
|
||||
self.check_current_focus()
|
||||
|
||||
def check_announcement(self):
|
||||
img = cropimg(self.gray, ((960, 0), (1920, 540)))
|
||||
tpl = loadres("announcement_close", True)
|
||||
msk = thres2(tpl, 1)
|
||||
result = cv2.matchTemplate(img, tpl, cv2.TM_SQDIFF_NORMED, None, msk)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
if min_val < 0.02:
|
||||
return (min_loc[0] + 960 + 42, min_loc[1] + 42)
|
||||
|
||||
def get_scene(self) -> int:
|
||||
"""get the current scene in the game"""
|
||||
if self.scene != Scene.UNDEFINED:
|
||||
return self.scene
|
||||
|
||||
# 连接中,优先级最高
|
||||
if self.find("connecting"):
|
||||
self.scene = Scene.CONNECTING
|
||||
|
||||
# 平均色匹配
|
||||
elif self.find("sanity_charge"):
|
||||
self.scene = Scene.SANITY_CHARGE
|
||||
elif self.find("sanity_charge_dialog"):
|
||||
self.scene = Scene.SANITY_CHARGE_DIALOG
|
||||
elif self.find("confirm"):
|
||||
self.scene = Scene.CONFIRM
|
||||
elif self.find("order_label"):
|
||||
self.scene = Scene.ORDER_LIST
|
||||
elif self.find("drone"):
|
||||
self.scene = Scene.DRONE_ACCELERATE
|
||||
elif self.find("factory_collect"):
|
||||
self.scene = Scene.FACTORY_ROOMS
|
||||
elif self.find("nav_bar"):
|
||||
self.scene = Scene.NAVIGATION_BAR
|
||||
elif self.find("mail"):
|
||||
self.scene = Scene.MAIL
|
||||
elif self.find("navigation/record_restoration"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("choose_agent/support_status"):
|
||||
self.scene = Scene.OPERATOR_SUPPORT
|
||||
elif self.find("fight/collection") or self.find("fight/collection_on"):
|
||||
self.scene = Scene.OPERATOR_AGENT_SELECT
|
||||
elif self.find("choose_agent/fast_select"):
|
||||
self.scene = Scene.OPERATOR_SELECT
|
||||
elif self.find("ope_eliminate"):
|
||||
self.scene = Scene.OPERATOR_ELIMINATE
|
||||
elif self.find("ope_elimi_agency_panel"):
|
||||
self.scene = Scene.OPERATOR_ELIMINATE_AGENCY
|
||||
elif self.find("riic/report_title"):
|
||||
self.scene = Scene.RIIC_REPORT
|
||||
elif self.find("control_central_assistants"):
|
||||
self.scene = Scene.CTRLCENTER_ASSISTANT
|
||||
elif self.find("infra_overview"):
|
||||
self.scene = Scene.INFRA_MAIN
|
||||
elif self.find("infra_todo"):
|
||||
self.scene = Scene.INFRA_TODOLIST
|
||||
elif self.find("clue"):
|
||||
self.scene = Scene.INFRA_CONFIDENTIAL
|
||||
elif self.find("infra_overview_in"):
|
||||
self.scene = Scene.INFRA_ARRANGE
|
||||
elif self.find("arrange_confirm"):
|
||||
self.scene = Scene.INFRA_ARRANGE_CONFIRM
|
||||
elif self.find("open_recruitment"):
|
||||
self.scene = Scene.RECRUIT_MAIN
|
||||
elif self.find("recruiting_instructions"):
|
||||
self.scene = Scene.RECRUIT_TAGS
|
||||
elif self.find("shop/trade_token_dialog"):
|
||||
self.scene = Scene.SHOP_TRADE_TOKEN
|
||||
elif self.find("shop/credit"):
|
||||
if config.recog.hsv[870][1530][1] > 50:
|
||||
self.scene = Scene.UNKNOWN
|
||||
else:
|
||||
self.scene = Scene.SHOP_CREDIT
|
||||
elif self.find("shop/token"):
|
||||
self.scene = Scene.SHOP_TOKEN
|
||||
elif self.find("shop/recommend"):
|
||||
self.scene = Scene.SHOP_OTHERS
|
||||
elif self.find("shop/recommend_off"):
|
||||
self.scene = Scene.SHOP_OTHERS
|
||||
elif self.find("shop/cart"):
|
||||
self.scene = Scene.SHOP_CREDIT_CONFIRM
|
||||
elif self.find("login_logo") and self.find("hypergryph"):
|
||||
if self.find("login_awake"):
|
||||
self.scene = Scene.LOGIN_QUICKLY
|
||||
elif self.find("login_account"):
|
||||
self.scene = Scene.LOGIN_MAIN
|
||||
else:
|
||||
self.scene = Scene.LOGIN_MAIN_NOENTRY
|
||||
elif self.find("12cadpa"):
|
||||
self.scene = Scene.LOGIN_START
|
||||
elif self.find("login_bilibili"):
|
||||
self.scene = Scene.LOGIN_BILIBILI
|
||||
elif self.find("skip"):
|
||||
self.scene = Scene.SKIP
|
||||
elif self.find("login_connecting"):
|
||||
self.scene = Scene.LOGIN_LOADING
|
||||
elif self.find("arrange_order_options"):
|
||||
self.scene = Scene.RIIC_OPERATOR_SELECT
|
||||
elif self.find("arrange_order_options_scene"):
|
||||
self.scene = Scene.INFRA_ARRANGE_ORDER
|
||||
elif self.find("ope_recover_potion_on"):
|
||||
self.scene = Scene.OPERATOR_RECOVER_POTION
|
||||
elif self.find("ope_recover_originite_on", scope=((1530, 120), (1850, 190))):
|
||||
self.scene = Scene.OPERATOR_RECOVER_ORIGINITE
|
||||
elif self.find("double_confirm/main"):
|
||||
if self.find("double_confirm/exit"):
|
||||
self.scene = Scene.EXIT_GAME
|
||||
elif self.find("double_confirm/friend"):
|
||||
self.scene = Scene.BACK_TO_FRIEND_LIST
|
||||
elif self.find("double_confirm/give_up"):
|
||||
self.scene = Scene.OPERATOR_GIVEUP
|
||||
elif self.find("double_confirm/infrastructure"):
|
||||
self.scene = Scene.LEAVE_INFRASTRUCTURE
|
||||
elif self.find("double_confirm/recruit"):
|
||||
self.scene = Scene.REFRESH_TAGS
|
||||
elif self.find("double_confirm/network"):
|
||||
self.scene = Scene.NETWORK_CHECK
|
||||
elif self.find("double_confirm/voice"):
|
||||
self.scene = Scene.DOWNLOAD_VOICE_RESOURCES
|
||||
elif self.find("double_confirm/sss"):
|
||||
self.scene = Scene.SSS_EXIT_CONFIRM
|
||||
elif self.find("double_confirm/product_plan"):
|
||||
self.scene = Scene.PRODUCT_SWITCHING_CONFIRM
|
||||
else:
|
||||
self.scene = Scene.DOUBLE_CONFIRM
|
||||
elif self.find("mission_trainee_on"):
|
||||
self.scene = Scene.MISSION_TRAINEE
|
||||
elif self.find("shop/spent_credit"):
|
||||
self.scene = Scene.SHOP_UNLOCK_SCHEDULE
|
||||
elif self.find("loading7"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("clue/daily"):
|
||||
self.scene = Scene.CLUE_DAILY
|
||||
elif self.find("clue/receive"):
|
||||
self.scene = Scene.CLUE_RECEIVE
|
||||
elif self.find("clue/give_away"):
|
||||
self.scene = Scene.CLUE_GIVE_AWAY
|
||||
elif self.find("clue/summary"):
|
||||
self.scene = Scene.CLUE_SUMMARY
|
||||
elif self.find("clue/filter_all"):
|
||||
self.scene = Scene.CLUE_PLACE
|
||||
elif self.find("upgrade"):
|
||||
self.scene = Scene.UPGRADE
|
||||
elif self.find("depot"):
|
||||
self.scene = Scene.DEPOT
|
||||
elif self.find("pull_once"):
|
||||
self.scene = Scene.HEADHUNTING
|
||||
elif self.find("read_and_agree") or self.find("next_step"):
|
||||
self.scene = Scene.AGREEMENT_UPDATE
|
||||
elif self.find("notice"):
|
||||
self.scene = Scene.NOTICE
|
||||
elif self.find("sss/main"):
|
||||
self.scene = Scene.SSS_MAIN
|
||||
elif self.find("sss/start"):
|
||||
self.scene = Scene.SSS_START
|
||||
elif self.find("sss/ec"):
|
||||
self.scene = Scene.SSS_EC
|
||||
elif self.find("sss/device"):
|
||||
self.scene = Scene.SSS_DEVICE
|
||||
elif self.find("sss/squad"):
|
||||
self.scene = Scene.SSS_SQUAD
|
||||
elif self.find("sss/deploy"):
|
||||
self.scene = Scene.SSS_DEPLOY
|
||||
elif self.find("sss/loading"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("sss/redeploy"):
|
||||
self.scene = Scene.SSS_REDEPLOY
|
||||
elif self.find("sss/terminated"):
|
||||
self.scene = Scene.SSS_TERMINATED
|
||||
elif self.find("login_captcha"):
|
||||
self.scene = Scene.LOGIN_CAPTCHA
|
||||
elif self.find("sign_in/banner"):
|
||||
self.scene = Scene.SIGN_IN_DAILY
|
||||
elif self.find("sign_in/moon_festival/banner"):
|
||||
self.scene = Scene.MOON_FESTIVAL
|
||||
elif self.find("navigation/activity/entry"):
|
||||
self.scene = Scene.ACTIVITY_MAIN
|
||||
elif self.find("navigation/activity/banner"):
|
||||
self.scene = Scene.ACTIVITY_CHOOSE_LEVEL
|
||||
elif self.is_black():
|
||||
self.scene = Scene.LOADING
|
||||
|
||||
# 模板匹配
|
||||
elif self.detect_index_scene():
|
||||
if self.match3d("originite")[0] >= 0.9:
|
||||
self.scene = Scene.INDEX_ORIGINITE
|
||||
elif self.match3d("sanity")[0] >= 0.8:
|
||||
self.scene = Scene.INDEX_SANITY
|
||||
else:
|
||||
self.scene = Scene.INDEX
|
||||
elif self.find("materiel_ico"):
|
||||
self.scene = Scene.MATERIEL
|
||||
elif self.find("loading"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("loading2"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("loading3"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("loading4"):
|
||||
self.scene = Scene.LOADING
|
||||
elif self.find("ope_plan"):
|
||||
self.scene = Scene.OPERATOR_BEFORE
|
||||
elif self.find("navigation/episode"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/AP-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/LS-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/CA-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/CE-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/SK-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/PR-A-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/PR-B-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/PR-C-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("navigation/collection/PR-D-1"):
|
||||
self.scene = Scene.OPERATOR_CHOOSE_LEVEL
|
||||
elif self.find("ope_agency_going"):
|
||||
self.scene = Scene.OPERATOR_ONGOING
|
||||
elif self.find("fight/gear"):
|
||||
self.scene = Scene.OPERATOR_FIGHT
|
||||
elif self.find("ope_finish"):
|
||||
self.scene = Scene.OPERATOR_FINISH
|
||||
elif self.find("fight/use"):
|
||||
self.scene = Scene.OPERATOR_SUPPORT_AGENT
|
||||
elif self.find("business_card"):
|
||||
self.scene = Scene.BUSINESS_CARD
|
||||
elif self.find("friend_list"):
|
||||
self.scene = Scene.FRIEND_LIST
|
||||
elif self.find("credit_visiting"):
|
||||
self.scene = Scene.FRIEND_VISITING
|
||||
elif self.find("arrange_check_in") or self.find("arrange_check_in_on"):
|
||||
self.scene = Scene.INFRA_DETAILS
|
||||
elif self.find("ope_failed"):
|
||||
self.scene = Scene.OPERATOR_FAILED
|
||||
elif self.find("mission_daily_on"):
|
||||
self.scene = Scene.MISSION_DAILY
|
||||
elif self.find("mission_weekly_on"):
|
||||
self.scene = Scene.MISSION_WEEKLY
|
||||
elif self.find("recruit/agent_token") or self.find("recruit/agent_token_first"):
|
||||
self.scene = Scene.RECRUIT_AGENT
|
||||
elif self.find("terminal_main"):
|
||||
self.scene = Scene.TERMINAL_MAIN
|
||||
elif self.find("main_theme"):
|
||||
self.scene = Scene.TERMINAL_MAIN_THEME
|
||||
elif self.find("episode"):
|
||||
self.scene = Scene.TERMINAL_EPISODE
|
||||
elif self.find("biography"):
|
||||
self.scene = Scene.TERMINAL_BIOGRAPHY
|
||||
elif self.find("collection"):
|
||||
self.scene = Scene.TERMINAL_COLLECTION
|
||||
elif self.find("terminal_regular"):
|
||||
self.scene = Scene.TERMINAL_REGULAR
|
||||
elif self.check_announcement():
|
||||
self.scene = Scene.ANNOUNCEMENT
|
||||
elif self.find("choose_product_options"):
|
||||
self.scene = Scene.CHOOSE_PRODUCT
|
||||
elif self.find("order_switching_notice"):
|
||||
self.scene = Scene.SWITCH_ORDER
|
||||
elif self.find("story_skip_confirm_dialog"):
|
||||
self.scene = Scene.STORY_SKIP
|
||||
elif self.find("story_skip"):
|
||||
self.scene = Scene.STORY
|
||||
|
||||
# 没弄完的
|
||||
# elif self.find("ope_elimi_finished"):
|
||||
# self.scene = Scene.OPERATOR_ELIMINATE_FINISH
|
||||
# elif self.find("shop/assist"):
|
||||
# self.scene = Scene.SHOP_ASSIST
|
||||
# elif self.find("login_bilibili_privacy"):
|
||||
# self.scene = Scene.LOGIN_BILIBILI_PRIVACY
|
||||
|
||||
# 兜底
|
||||
elif self.find("nav_button"):
|
||||
self.scene = Scene.UNKNOWN_WITH_NAVBAR
|
||||
|
||||
else:
|
||||
self.scene = Scene.UNKNOWN
|
||||
self.check_current_focus()
|
||||
|
||||
logger.info(f"Scene {self.scene}: {SceneComment[self.scene]}")
|
||||
|
||||
return self.scene
|
||||
|
||||
def find_ra_battle_exit(self) -> bool:
|
||||
im = cv2.cvtColor(self.img, cv2.COLOR_RGB2HSV)
|
||||
im = cv2.inRange(im, (29, 0, 0), (31, 255, 255))
|
||||
score, scope = self.template_match(
|
||||
"ra/battle_exit", ((75, 47), (165, 126)), cv2.TM_CCOEFF_NORMED
|
||||
)
|
||||
return scope if score > 0.8 else None
|
||||
|
||||
def detect_ra_adventure(self) -> bool:
|
||||
img = cropimg(self.gray, ((385, 365), (475, 465)))
|
||||
img = thres2(img, 250)
|
||||
res = loadres("ra/adventure", True)
|
||||
result = cv2.matchTemplate(img, res, cv2.TM_CCOEFF_NORMED)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
logger.debug(f"{max_val=} {max_loc=}")
|
||||
return max_val >= 0.9
|
||||
|
||||
def get_ra_scene(self) -> int:
|
||||
"""
|
||||
生息演算场景识别
|
||||
"""
|
||||
# 场景缓存
|
||||
if self.scene != Scene.UNDEFINED:
|
||||
return self.scene
|
||||
|
||||
# 连接中,优先级最高
|
||||
if self.find("connecting"):
|
||||
self.scene = Scene.CONNECTING
|
||||
elif self.find("loading"):
|
||||
self.scene = Scene.UNKNOWN
|
||||
elif self.find("loading4"):
|
||||
self.scene = Scene.UNKNOWN
|
||||
|
||||
# 奇遇
|
||||
elif self.detect_ra_adventure():
|
||||
self.scene = Scene.RA_ADVENTURE
|
||||
|
||||
# 快速跳过剧情对话
|
||||
elif self.find("ra/guide_dialog"):
|
||||
self.scene = Scene.RA_GUIDE_DIALOG
|
||||
|
||||
# 快速退出作战
|
||||
elif self.find_ra_battle_exit():
|
||||
self.scene = Scene.RA_BATTLE
|
||||
elif self.find("ra/battle_exit_dialog"):
|
||||
self.scene = Scene.RA_BATTLE_EXIT_CONFIRM
|
||||
|
||||
# 作战与分队
|
||||
elif self.find("ra/squad_edit"):
|
||||
self.scene = Scene.RA_SQUAD_EDIT
|
||||
elif self.find("ra/start_action"):
|
||||
if self.find("ra/action_points"):
|
||||
self.scene = Scene.RA_BATTLE_ENTRANCE
|
||||
else:
|
||||
self.scene = Scene.RA_GUIDE_BATTLE_ENTRANCE
|
||||
elif self.find("ra/get_item"):
|
||||
self.scene = Scene.RA_GET_ITEM
|
||||
elif self.find("ra/return_from_kitchen"):
|
||||
self.scene = Scene.RA_KITCHEN
|
||||
elif self.find("ra/squad_edit_confirm_dialog"):
|
||||
self.scene = Scene.RA_SQUAD_EDIT_DIALOG
|
||||
elif self.find("ra/enter_battle_confirm_dialog"):
|
||||
self.scene = Scene.RA_SQUAD_ABNORMAL
|
||||
elif self.find("ra/battle_complete"):
|
||||
self.scene = Scene.RA_BATTLE_COMPLETE
|
||||
|
||||
# 结算界面
|
||||
elif self.find("ra/day_complete"):
|
||||
self.scene = Scene.RA_DAY_COMPLETE
|
||||
elif self.find("ra/period_complete") and self.find("ra/click_anywhere"):
|
||||
self.scene = Scene.RA_PERIOD_COMPLETE
|
||||
|
||||
# 森蚺图耶对话
|
||||
elif self.find("ra/guide_entrance"):
|
||||
self.scene = Scene.RA_GUIDE_ENTRANCE
|
||||
|
||||
# 存档操作
|
||||
elif self.find("ra/delete_save_confirm_dialog"):
|
||||
self.scene = Scene.RA_DELETE_SAVE_DIALOG
|
||||
|
||||
# 地图识别
|
||||
elif self.find("ra/waste_time_button"):
|
||||
self.scene = Scene.RA_DAY_DETAIL
|
||||
elif self.find("ra/waste_time_dialog"):
|
||||
self.scene = Scene.RA_WASTE_TIME_DIALOG
|
||||
elif self.find("ra/map_back", thres=200) and self.color(1817, 333)[0] > 250:
|
||||
self.scene = Scene.RA_MAP
|
||||
|
||||
# 一张便条
|
||||
elif self.find("ra/notice"):
|
||||
self.scene = Scene.RA_NOTICE
|
||||
|
||||
# 一张便条
|
||||
elif self.find("ra/no_enough_drink"):
|
||||
self.scene = Scene.RA_INSUFFICIENT_DRINK
|
||||
|
||||
# 从首页选择终端进入生息演算主页
|
||||
elif self.find("terminal_longterm"):
|
||||
self.scene = Scene.TERMINAL_LONGTERM
|
||||
elif self.find("ra/main_title"):
|
||||
self.scene = Scene.RA_MAIN
|
||||
elif self.detect_index_scene():
|
||||
self.scene = Scene.INDEX
|
||||
elif self.find("terminal_main"):
|
||||
self.scene = Scene.TERMINAL_MAIN
|
||||
else:
|
||||
self.scene = Scene.UNKNOWN
|
||||
self.check_current_focus()
|
||||
|
||||
log_msg = f"Scene: {self.scene}: {SceneComment[self.scene]}"
|
||||
if self.scene == Scene.UNKNOWN:
|
||||
logger.debug(log_msg)
|
||||
else:
|
||||
logger.info(log_msg)
|
||||
|
||||
self.check_loading_time()
|
||||
|
||||
return self.scene
|
||||
|
||||
def get_sf_scene(self) -> int:
|
||||
"""
|
||||
隐秘战线场景识别
|
||||
"""
|
||||
# 场景缓存
|
||||
if self.scene != Scene.UNDEFINED:
|
||||
return self.scene
|
||||
|
||||
# 连接中,优先级最高
|
||||
if self.find("connecting"):
|
||||
self.scene = Scene.CONNECTING
|
||||
|
||||
elif self.find("notice"):
|
||||
self.scene = Scene.NOTICE
|
||||
|
||||
elif self.find("sf/success") or self.find("sf/failure"):
|
||||
self.scene = Scene.SF_RESULT
|
||||
elif self.find("sf/continue"):
|
||||
self.scene = Scene.SF_CONTINUE
|
||||
elif self.find("sf/select"):
|
||||
self.scene = Scene.SF_SELECT
|
||||
elif self.find("sf/properties"):
|
||||
self.scene = Scene.SF_ACTIONS
|
||||
elif self.find("sf/continue_event"):
|
||||
self.scene = Scene.SF_EVENT
|
||||
elif self.find("sf/team_pass"):
|
||||
self.scene = Scene.SF_TEAM_PASS
|
||||
|
||||
elif self.find("sf/inheritance", scope=((1490, 0), (1920, 100))):
|
||||
self.scene = Scene.SF_SELECT_TEAM
|
||||
|
||||
# 从首页进入隐秘战线
|
||||
elif self.detect_index_scene():
|
||||
self.scene = Scene.INDEX
|
||||
elif self.find("terminal_main"):
|
||||
self.scene = Scene.TERMINAL_MAIN
|
||||
elif self.find("main_theme"):
|
||||
self.scene = Scene.TERMINAL_MAIN_THEME
|
||||
elif self.find("sf/entrance"):
|
||||
self.scene = Scene.SF_ENTRANCE
|
||||
|
||||
elif self.find("sf/click_anywhere"):
|
||||
self.scene = Scene.SF_CLICK_ANYWHERE
|
||||
elif self.find("sf/end"):
|
||||
self.scene = Scene.SF_END
|
||||
elif self.find("sf/exit"):
|
||||
self.scene = Scene.SF_EXIT
|
||||
|
||||
else:
|
||||
self.scene = Scene.UNKNOWN
|
||||
self.check_current_focus()
|
||||
|
||||
log_msg = f"Scene: {self.scene}: {SceneComment[self.scene]}"
|
||||
if self.scene == Scene.UNKNOWN:
|
||||
logger.debug(log_msg)
|
||||
else:
|
||||
logger.info(log_msg)
|
||||
|
||||
self.check_loading_time()
|
||||
|
||||
return self.scene
|
||||
|
||||
def get_train_scene(self) -> int:
|
||||
"""
|
||||
训练室场景识别
|
||||
"""
|
||||
# 场景缓存
|
||||
if self.scene != Scene.UNDEFINED:
|
||||
return self.scene
|
||||
# 连接中,优先级最高
|
||||
if self.find("connecting"):
|
||||
self.scene = Scene.CONNECTING
|
||||
elif self.find("infra_overview"):
|
||||
self.scene = Scene.INFRA_MAIN
|
||||
elif self.find("train_main"):
|
||||
self.scene = Scene.TRAIN_MAIN
|
||||
elif self.find("skill_collect_confirm", scope=((1142, 831), (1282, 932))):
|
||||
self.scene = Scene.TRAIN_FINISH
|
||||
elif self.find("training_support"):
|
||||
self.scene = Scene.TRAIN_SKILL_SELECT
|
||||
elif self.find("upgrade_failure"):
|
||||
self.scene = Scene.TRAIN_SKILL_UPGRADE_ERROR
|
||||
elif self.find("skill_confirm"):
|
||||
self.scene = Scene.TRAIN_SKILL_UPGRADE
|
||||
else:
|
||||
self.scene = Scene.UNKNOWN
|
||||
self.check_current_focus()
|
||||
|
||||
logger.info(f"Scene: {self.scene}: {SceneComment[self.scene]}")
|
||||
|
||||
self.check_loading_time()
|
||||
|
||||
return self.scene
|
||||
|
||||
def is_black(self) -> None:
|
||||
"""check if the current scene is all black"""
|
||||
return np.max(self.gray[:, 105:-105]) < 16
|
||||
|
||||
def find(
|
||||
self,
|
||||
res: tp.Res,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope | None = None,
|
||||
thres: int | None = None,
|
||||
judge: bool = True,
|
||||
strict: bool = False,
|
||||
threshold: float = 0.0,
|
||||
) -> tp.Scope:
|
||||
"""
|
||||
查找元素是否出现在画面中
|
||||
|
||||
:param res: 待识别元素资源文件名
|
||||
:param draw: 是否将识别结果输出到屏幕
|
||||
:param scope: ((x0, y0), (x1, y1)),提前限定元素可能出现的范围
|
||||
:param thres: 是否在匹配前对图像进行二值化处理
|
||||
:param judge: 是否加入更加精确的判断
|
||||
:param strict: 是否启用严格模式,未找到时报错
|
||||
:param score: 是否启用分数限制,有些图片精确识别需要提高分数阈值
|
||||
|
||||
:return ret: 若匹配成功,则返回元素在游戏界面中出现的位置,否则返回 None
|
||||
"""
|
||||
|
||||
if res in color:
|
||||
res_img = loadres(res)
|
||||
h, w, _ = res_img.shape
|
||||
|
||||
pos_list = color[res]
|
||||
if not isinstance(pos_list[0], tuple):
|
||||
pos_list = [color[res]]
|
||||
for pos in pos_list:
|
||||
scope = pos, va(pos, (w, h))
|
||||
img = cropimg(self.img, scope)
|
||||
if cmatch(img, res_img, draw=draw):
|
||||
gray = cropimg(self.gray, scope)
|
||||
res_img = cv2.cvtColor(res_img, cv2.COLOR_RGB2GRAY)
|
||||
ssim = structural_similarity(gray, res_img)
|
||||
if ssim >= 0.9:
|
||||
logger.debug(f"cmatch+SSIM: {res=} {scope=}")
|
||||
return scope
|
||||
|
||||
return None
|
||||
|
||||
if res in template_matching:
|
||||
threshold = 0.9
|
||||
if res in template_matching_score:
|
||||
threshold = template_matching_score[res]
|
||||
|
||||
res_img = loadres(res, True)
|
||||
h, w = res_img.shape
|
||||
|
||||
pos = template_matching[res] or scope
|
||||
if isinstance(pos[0], tuple):
|
||||
scope = pos
|
||||
else:
|
||||
scope = pos, va(pos, (w, h))
|
||||
|
||||
img = cropimg(self.gray, scope)
|
||||
result = cv2.matchTemplate(img, res_img, cv2.TM_CCOEFF_NORMED)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
if max_val >= threshold:
|
||||
top_left = va(max_loc, scope[0])
|
||||
scope = top_left, va(top_left, (w, h))
|
||||
logger.debug(f"template matching: {res=} {scope=}")
|
||||
return scope
|
||||
return None
|
||||
|
||||
dpi_aware = res in [
|
||||
"control_central",
|
||||
]
|
||||
|
||||
if scope is None and threshold == 0.0:
|
||||
if res == "training_completed":
|
||||
scope = ((550, 900), (800, 1080))
|
||||
threshold = 0.45
|
||||
|
||||
logger.debug(f"feature matching: {res=}")
|
||||
res_img = loadres(res, True)
|
||||
if thres is not None:
|
||||
# 对图像二值化处理
|
||||
res_img = thres2(res_img, thres)
|
||||
matcher = Matcher(thres2(self.gray, thres))
|
||||
else:
|
||||
matcher = self.matcher
|
||||
ret = matcher.match_old(
|
||||
res_img,
|
||||
draw=draw,
|
||||
scope=scope,
|
||||
judge=judge,
|
||||
prescore=threshold,
|
||||
dpi_aware=dpi_aware,
|
||||
)
|
||||
if strict and ret is None:
|
||||
raise RecognizeError(f"Can't find '{res}'")
|
||||
return ret
|
||||
|
||||
def template_match(
|
||||
self,
|
||||
res: str,
|
||||
scope: Optional[tp.Scope] = None,
|
||||
method: int = cv2.TM_CCOEFF_NORMED,
|
||||
) -> Tuple[float, tp.Scope]:
|
||||
logger.debug(f"{res}=")
|
||||
|
||||
template = loadres(res, True)
|
||||
w, h = template.shape[::-1]
|
||||
|
||||
if scope:
|
||||
x, y = scope[0]
|
||||
img = cropimg(self.gray, scope)
|
||||
else:
|
||||
x, y = (0, 0)
|
||||
img = self.gray
|
||||
|
||||
result = cv2.matchTemplate(img, template, method)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
|
||||
if method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]:
|
||||
top_left = min_loc
|
||||
score = min_val
|
||||
else:
|
||||
top_left = max_loc
|
||||
score = max_val
|
||||
|
||||
p1 = (top_left[0] + x, top_left[1] + y)
|
||||
p2 = (p1[0] + w, p1[1] + h)
|
||||
|
||||
scope = p1, p2
|
||||
logger.debug(f"{score=} {scope=}")
|
||||
return score, scope
|
||||
|
||||
def match(
|
||||
self, res: tp.Res, draw: bool = False, scope: tp.Scope | None = None
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
logger.debug(f"{res=}")
|
||||
return self.matcher.match(loadres(res, True), draw, scope)
|
||||
|
||||
def match2d(
|
||||
self, res: tp.Res, draw: bool = False, scope: tp.Scope | None = None
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
logger.debug(f"{res=}")
|
||||
return self.matcher.match2d(loadres(res, True), draw, scope)
|
||||
|
||||
def match3d(
|
||||
self, res: tp.Res, draw: bool = False, scope: tp.Scope | None = None
|
||||
) -> tuple[float, tp.Scope | None]:
|
||||
logger.debug(f"{res=}")
|
||||
return self.matcher.match3d(loadres(res, True), draw, scope)
|
266
mower/utils/recognize/data.py
Normal file
266
mower/utils/recognize/data.py
Normal file
|
@ -0,0 +1,266 @@
|
|||
color = {
|
||||
"12cadpa": (1810, 21),
|
||||
"1800": (158, 958),
|
||||
"arrange_order_options": (1652, 23),
|
||||
"arrange_order_options_scene": (369, 199),
|
||||
"choose_agent/clear": (685, 996),
|
||||
"choose_agent/fast_select": ((1296, 25), (1592, 98)),
|
||||
"choose_agent/support_status": (1038, 41),
|
||||
"clue": (1740, 855),
|
||||
"clue/daily": (526, 623),
|
||||
"clue/filter_all": (1297, 99),
|
||||
"clue/give_away": (25, 18),
|
||||
"clue/receive": (1295, 15),
|
||||
"clue/summary": (59, 153),
|
||||
"confirm": (0, 683),
|
||||
"control_central_assistants": (39, 560),
|
||||
"depot": (0, 955),
|
||||
"double_confirm/exit": (940, 464),
|
||||
"double_confirm/friend": (978, 465),
|
||||
"double_confirm/give_up": (574, 716),
|
||||
"double_confirm/infrastructure": (1077, 435),
|
||||
"double_confirm/main": (835, 683),
|
||||
"double_confirm/network": (708, 435),
|
||||
"double_confirm/product_plan": (921, 461),
|
||||
"double_confirm/recruit": (981, 464),
|
||||
"double_confirm/voice": (745, 435),
|
||||
"drone": (274, 437),
|
||||
"factory_collect": (1542, 886),
|
||||
"fight/collection": (1088, 25),
|
||||
"fight/collection_on": (1084, 22),
|
||||
"fight/refresh": (1639, 22),
|
||||
"hypergryph": (0, 961),
|
||||
"infra_overview": (54, 135),
|
||||
"infra_overview_in": (64, 705),
|
||||
"infra_overview_top_right": (1820, 0),
|
||||
"infra_todo": (13, 1013),
|
||||
"loading2": (620, 247),
|
||||
"loading7": (106, 635),
|
||||
"login_account": (622, 703),
|
||||
"login_awake": (888, 743),
|
||||
"login_bilibili": (870, 282),
|
||||
"login_captcha": (651, 814),
|
||||
"login_connecting": (760, 881),
|
||||
"login_loading": (920, 388),
|
||||
"login_logo": (601, 332),
|
||||
"mail": (307, 39),
|
||||
"mission_trainee_on": (690, 17),
|
||||
"nav_bar": (655, 0),
|
||||
"nav_button": (26, 20),
|
||||
"navigation/collection/AP-1": (203, 821),
|
||||
"navigation/collection/CA-1": (203, 821),
|
||||
"navigation/collection/CE-1": (243, 822),
|
||||
"navigation/collection/LS-1": (240, 822),
|
||||
"navigation/collection/PR-A-1": (550, 629),
|
||||
"navigation/collection/PR-B-1": (496, 629),
|
||||
"navigation/collection/PR-C-1": (487, 586),
|
||||
"navigation/collection/PR-D-1": (516, 619),
|
||||
"navigation/collection/SK-1": (204, 821),
|
||||
"navigation/ope_hard_small": (819, 937),
|
||||
"navigation/ope_normal_small": (494, 930),
|
||||
"navigation/record_restoration": (274, 970),
|
||||
"next_step": (915, 811),
|
||||
"notice": (155, 132),
|
||||
"ope_agency_lock": [(1565, 856), (1565, 875)],
|
||||
"ope_elimi_agency_confirm": (1554, 941),
|
||||
"ope_elimi_agency_panel": (1409, 612),
|
||||
"ope_eliminate": (1332, 938),
|
||||
"ope_recover_originite_on": (1514, 124),
|
||||
"ope_recover_potion_on": (1046, 127),
|
||||
"open_recruitment": (192, 143),
|
||||
"order_label": (404, 137),
|
||||
"pull_once": (1260, 950),
|
||||
"read_and_agree": (1115, 767),
|
||||
"recruiting_instructions": (343, 179),
|
||||
"riic/exp": (1385, 239),
|
||||
"riic/manufacture": (1328, 126),
|
||||
"riic/report_title": (1712, 25),
|
||||
"room_detail": (1291, 33),
|
||||
"sanity_charge": (1111, 382),
|
||||
"sanity_charge_dialog": (570, 529),
|
||||
"shop/cart": (1252, 842),
|
||||
"shop/commendation": (24, 224),
|
||||
"shop/credit": (1646, 120),
|
||||
"shop/recommend": (0, 120),
|
||||
"shop/recommend_off": (0, 130),
|
||||
"shop/spent_credit": (332, 264),
|
||||
"shop/token": (1097, 120),
|
||||
"shop/trade_token_button": (15, 998),
|
||||
"shop/trade_token_dialog": (717, 694),
|
||||
"sign_in/banner": (205, 700),
|
||||
"sign_in/moon_festival/moon_cake": (1216, 503),
|
||||
"skip": (1803, 32),
|
||||
"sss/deploy": (1645, 971),
|
||||
"sss/device": (1644, 969),
|
||||
"sss/ec": (1644, 969),
|
||||
"sss/loading": (1642, 517),
|
||||
"sss/main": (1224, 60),
|
||||
"sss/redeploy": (1644, 970),
|
||||
"sss/squad": (1645, 970),
|
||||
"sss/start": (1547, 968),
|
||||
"sss/terminated": (29, 241),
|
||||
"terminal_main": (1658, 734),
|
||||
}
|
||||
|
||||
template_matching = {
|
||||
"arrange_check_in": ((30, 300), (175, 700)),
|
||||
"arrange_check_in_on": ((30, 300), (175, 700)),
|
||||
"arrange_confirm": (755, 903),
|
||||
"biography": (768, 934),
|
||||
"business_card": (55, 165),
|
||||
"collection": (1005, 943),
|
||||
"collection_small": (1053, 982),
|
||||
"connecting": (1087, 978),
|
||||
"credit_visiting": (78, 220),
|
||||
"choose_agent/battle_confirm": (1591, 991),
|
||||
"choose_agent/battle_empty": (92, 296),
|
||||
"choose_agent/profession/ALL": (1828, 46),
|
||||
"choose_agent/profession/choose_arrow": ((1850, 125), (1920, 1080)),
|
||||
"choose_agent/profession/CASTER": (1825, 630),
|
||||
"choose_agent/profession/MEDIC": (1832, 750),
|
||||
"choose_agent/profession/PIONEER": (1831, 160),
|
||||
"choose_agent/profession/SNIPER": (1829, 505),
|
||||
"choose_agent/profession/SPECIAL": (1835, 994),
|
||||
"choose_agent/profession/SUPPORT": (1828, 872),
|
||||
"choose_agent/profession/TANK": (1832, 394),
|
||||
"choose_agent/profession/WARRIOR": (1825, 271),
|
||||
"choose_agent/support_skill_be_choosen": None,
|
||||
"choose_product_options": (1174, 23),
|
||||
"episode": (535, 937),
|
||||
"factory_accelerate": (1800, 775),
|
||||
"fight/gear": (82, 45),
|
||||
"fight/use": (1037, 845),
|
||||
"friend_list": (61, 306),
|
||||
"icon_notification_black": ((1436, 129), (1920, 221)),
|
||||
"infra_complete/信用": None,
|
||||
"infra_complete/先锋双芯片": None,
|
||||
"infra_complete/医疗双芯片": None,
|
||||
"infra_complete/合成玉": None,
|
||||
"infra_complete/术师双芯片": None,
|
||||
"infra_complete/源石碎片": None,
|
||||
"infra_complete/特种双芯片": None,
|
||||
"infra_complete/狙击双芯片": None,
|
||||
"infra_complete/经验": None,
|
||||
"infra_complete/赤金": None,
|
||||
"infra_complete/辅助双芯片": None,
|
||||
"infra_complete/近卫双芯片": None,
|
||||
"infra_complete/重装双芯片": None,
|
||||
"infra_complete/龙门币": None,
|
||||
"infra_no_operator": None,
|
||||
"loading": (736, 333),
|
||||
"loading2": (620, 247),
|
||||
"loading3": (1681, 1000),
|
||||
"loading4": (828, 429),
|
||||
"main_theme": (283, 945),
|
||||
"main_theme_small": (321, 973),
|
||||
"materiel_ico": (892, 61),
|
||||
"mission_daily_on": ((685, 15), (1910, 100)),
|
||||
"mission_weekly_on": ((685, 15), (1910, 100)),
|
||||
"navigation/activity/banner": (1468, 855),
|
||||
"navigation/activity/entry": (1256, 829),
|
||||
"navigation/collection/AP_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/CA_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/CE_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/LS_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/PR-A_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/PR-B_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/PR-C_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/PR-D_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/collection/SK_entry": ((0, 170), (1920, 870)),
|
||||
"navigation/episode": (1567, 949),
|
||||
"navigation/ope_difficulty": [(0, 920), (120, 1080)],
|
||||
"navigation/ope_hard": (172, 950),
|
||||
"navigation/ope_hard_small": (819, 937),
|
||||
"navigation/ope_normal": (172, 950),
|
||||
"navigation/ope_normal_small": (494, 930),
|
||||
"ope_agency_fail": (809, 959),
|
||||
"ope_agency_going": ((508, 941), (715, 1021)),
|
||||
"ope_failed": (183, 465),
|
||||
"ope_finish": (87, 265),
|
||||
"ope_plan": (1278, 24),
|
||||
"ope_select_start": ((1579, 701), (1731, 921)),
|
||||
"ope_select_start_empty": ((0, 0), (400, 400)),
|
||||
"order_ready": (500, 664),
|
||||
"order_switching_notice": (604, 900),
|
||||
"product/先锋双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/医疗双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/术师双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/源石碎片": ((1635, 445), (1730, 520)),
|
||||
"product/特种双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/狙击双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/经验": ((1635, 445), (1730, 520)),
|
||||
"product/赤金": ((1635, 445), (1730, 520)),
|
||||
"product/辅助双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/近卫双芯片": ((1635, 445), (1730, 520)),
|
||||
"product/重装双芯片": ((1635, 445), (1730, 520)),
|
||||
"recruit/agent_token": ((1740, 765), (1920, 805)),
|
||||
"recruit/agent_token_first": ((1700, 760), (1920, 810)),
|
||||
"recruit/available_level": (1294, 234),
|
||||
"recruit/begin_recruit": None,
|
||||
"recruit/career_needs": (350, 593),
|
||||
"recruit/job_requirements": None,
|
||||
"recruit/lmb": (945, 27),
|
||||
"recruit/recruit_done": None,
|
||||
"recruit/recruit_lock": None,
|
||||
"recruit/refresh": (1366, 560),
|
||||
"recruit/refresh_comfirm": (1237, 714),
|
||||
"recruit/riic_res/CASTER": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/MEDIC": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/PIONEER": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/SNIPER": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/SPECIAL": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/SUPPORT": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/TANK": ((750, 730), (1920, 860)),
|
||||
"recruit/riic_res/WARRIOR": ((750, 730), (1920, 860)),
|
||||
"recruit/start_recruit": (1438, 849),
|
||||
"recruit/stone": ((900, 0), (1920, 120)),
|
||||
"recruit/ticket": ((900, 0), (1920, 120)),
|
||||
"recruit/time": (1304, 112),
|
||||
"reload_check": (1252, 772),
|
||||
"riic/assistants": ((1320, 400), (1600, 650)),
|
||||
"riic/iron": ((1570, 230), (1630, 340)),
|
||||
"riic/orundum": ((1500, 320), (1800, 550)),
|
||||
"riic/trade": ((1320, 250), (1600, 500)),
|
||||
"sign_in/moon_festival/banner": (704, 92),
|
||||
"stone_fragment": None,
|
||||
"story_skip": (1718, 58),
|
||||
"story_skip_confirm_dialog": (685, 655),
|
||||
"switch_order/check": None,
|
||||
"switch_order/lmb": (1442, 891),
|
||||
"switch_order/oru": (1442, 891),
|
||||
"terminal_regular": (1247, 980),
|
||||
"upgrade": (997, 501),
|
||||
}
|
||||
|
||||
template_matching_score = {
|
||||
"connecting": 0.7,
|
||||
"choose_agent/profession/ALL": 0.6,
|
||||
"choose_agent/profession/CASTER": 0.6,
|
||||
"choose_agent/profession/MEDIC": 0.6,
|
||||
"choose_agent/profession/PIONEER": 0.6,
|
||||
"choose_agent/profession/SNIPER": 0.6,
|
||||
"choose_agent/profession/SPECIAL": 0.6,
|
||||
"choose_agent/profession/SUPPORT": 0.6,
|
||||
"choose_agent/profession/TANK": 0.6,
|
||||
"choose_agent/profession/WARRIOR": 0.6,
|
||||
"navigation/episode": 0.7,
|
||||
"navigation/ope_hard": 0.7,
|
||||
"navigation/ope_hard_small": 0.7,
|
||||
"navigation/ope_normal": 0.7,
|
||||
"navigation/ope_normal_small": 0.7,
|
||||
"ope_select_start": 0.7,
|
||||
"recruit/agent_token": 0.8,
|
||||
"recruit/agent_token_first": 0.8,
|
||||
"recruit/lmb": 0.7,
|
||||
"recruit/riic_res/CASTER": 0.7,
|
||||
"recruit/riic_res/MEDIC": 0.7,
|
||||
"recruit/riic_res/PIONEER": 0.7,
|
||||
"recruit/riic_res/SNIPER": 0.7,
|
||||
"recruit/riic_res/SPECIAL": 0.7,
|
||||
"recruit/riic_res/SUPPORT": 0.7,
|
||||
"recruit/riic_res/TANK": 0.7,
|
||||
"recruit/riic_res/WARRIOR": 0.7,
|
||||
"recruit/stone": 0.7,
|
||||
"recruit/time": 0.8,
|
||||
"sign_in/moon_festival/banner": 0.5,
|
||||
}
|
479
mower/utils/scene.py
Normal file
479
mower/utils/scene.py
Normal file
|
@ -0,0 +1,479 @@
|
|||
class Scene:
|
||||
UNKNOWN_WITH_NAVBAR = -2
|
||||
"有导航栏的未知场景"
|
||||
UNKNOWN = -1
|
||||
"未知"
|
||||
UNDEFINED = 0
|
||||
"未定义"
|
||||
INDEX = 1
|
||||
"首页"
|
||||
MATERIEL = 2
|
||||
"物资领取确认"
|
||||
ANNOUNCEMENT = 3
|
||||
"公告"
|
||||
MAIL = 4
|
||||
"邮件信箱"
|
||||
NAVIGATION_BAR = 5
|
||||
"导航栏返回"
|
||||
UPGRADE = 6
|
||||
"升级"
|
||||
SKIP = 7
|
||||
"开包动画"
|
||||
DOUBLE_CONFIRM = 8
|
||||
"二次确认(未知)"
|
||||
CONNECTING = 9
|
||||
"正在提交反馈至神经"
|
||||
NETWORK_CHECK = 10
|
||||
"网络拨测"
|
||||
EXIT_GAME = 11
|
||||
"退出游戏"
|
||||
DOWNLOAD_VOICE_RESOURCES = 12
|
||||
"检测到有未下载的语音资源"
|
||||
AGREEMENT_UPDATE = 13
|
||||
"协议更新"
|
||||
NOTICE = 14
|
||||
"说明"
|
||||
INDEX_ORIGINITE = 15
|
||||
"首页源石换玉"
|
||||
INDEX_SANITY = 16
|
||||
"首页源石换理智"
|
||||
STORY = 17
|
||||
"作战剧情"
|
||||
STORY_SKIP = 18
|
||||
"作战剧情"
|
||||
LOGIN_MAIN = 101
|
||||
"登录页面"
|
||||
LOGIN_INPUT = 102
|
||||
"登录页面(输入)"
|
||||
LOGIN_QUICKLY = 103
|
||||
"登录页面(快速)"
|
||||
LOGIN_LOADING = 104
|
||||
"登录中"
|
||||
LOGIN_START = 105
|
||||
"启动"
|
||||
LOGIN_ANNOUNCE = 106
|
||||
"启动界面公告"
|
||||
LOGIN_REGISTER = 107
|
||||
"注册"
|
||||
LOGIN_CAPTCHA = 108
|
||||
"滑动验证码"
|
||||
LOGIN_BILIBILI = 109
|
||||
"B 服登录界面"
|
||||
LOGIN_MAIN_NOENTRY = 110
|
||||
"登录页面(无按钮入口)"
|
||||
LOGIN_CADPA_DETAIL = 111
|
||||
"游戏适龄提示"
|
||||
CLOSE_MINE = 112
|
||||
"产业合作洽谈会"
|
||||
CHECK_IN = 113
|
||||
"4周年签到"
|
||||
LOGIN_NEW = 114
|
||||
"新登陆界面"
|
||||
LOGIN_BILIBILI_PRIVACY = 116
|
||||
"B服隐私政策提示"
|
||||
INFRA_MAIN = 201
|
||||
"基建全局视角"
|
||||
INFRA_TODOLIST = 202
|
||||
"基建待办事项"
|
||||
INFRA_CONFIDENTIAL = 203
|
||||
"线索主界面"
|
||||
INFRA_ARRANGE = 204
|
||||
"基建干员进驻总览"
|
||||
INFRA_DETAILS = 205
|
||||
"基建放大查看"
|
||||
INFRA_ARRANGE_CONFIRM = 206
|
||||
"基建干员排班二次确认"
|
||||
INFRA_ARRANGE_ORDER = 207
|
||||
"干员进驻设施排序界面"
|
||||
RIIC_REPORT = 208
|
||||
"副手简报界面"
|
||||
CTRLCENTER_ASSISTANT = 209
|
||||
"控制中枢界面"
|
||||
RIIC_OPERATOR_SELECT = 210
|
||||
"干员选择界面"
|
||||
CLUE_DAILY = 211
|
||||
"每日线索领取"
|
||||
CLUE_RECEIVE = 212
|
||||
"接收线索"
|
||||
CLUE_GIVE_AWAY = 213
|
||||
"传递线索"
|
||||
CLUE_SUMMARY = 214
|
||||
"线索交流活动汇总"
|
||||
CLUE_PLACE = 215
|
||||
"放置线索"
|
||||
TRAIN_SKILL_UPGRADE = 216
|
||||
"技能升级"
|
||||
TRAIN_MAIN = 217
|
||||
"训练室主界面"
|
||||
TRAIN_SKILL_UPGRADE_ERROR = 218
|
||||
"技能升级失败"
|
||||
TRAIN_SKILL_SELECT = 219
|
||||
"选择技能"
|
||||
TRAIN_FINISH = 220
|
||||
"技能升级结算"
|
||||
ORDER_LIST = 221
|
||||
"贸易站订单列表"
|
||||
DRONE_ACCELERATE = 222
|
||||
"无人机加速对话框"
|
||||
FACTORY_ROOMS = 223
|
||||
"制造站设施列表"
|
||||
LEAVE_INFRASTRUCTURE = 224
|
||||
"离开基建"
|
||||
SANITY_CHARGE = 225
|
||||
"急速充能"
|
||||
SANITY_CHARGE_DIALOG = 226
|
||||
"急速充能对话框"
|
||||
CHOOSE_PRODUCT = 227
|
||||
"制造站产物选择"
|
||||
SWITCH_ORDER = 228
|
||||
"订单切换选择"
|
||||
PRODUCT_SWITCHING_CONFIRM = 229
|
||||
"产物更改确认"
|
||||
BUSINESS_CARD = 301
|
||||
"个人名片"
|
||||
FRIEND_LIST = 302
|
||||
"好友列表"
|
||||
FRIEND_VISITING = 303
|
||||
"基建内访问好友"
|
||||
BACK_TO_FRIEND_LIST = 304
|
||||
"返回好友列表"
|
||||
MISSION_DAILY = 401
|
||||
"日常任务"
|
||||
MISSION_WEEKLY = 402
|
||||
"周常任务"
|
||||
MISSION_TRAINEE = 403
|
||||
"见习任务"
|
||||
TERMINAL_MAIN = 501
|
||||
"终端主界面"
|
||||
TERMINAL_MAIN_THEME = 502
|
||||
"主题曲"
|
||||
TERMINAL_EPISODE = 503
|
||||
"插曲"
|
||||
TERMINAL_BIOGRAPHY = 504
|
||||
"别传"
|
||||
TERMINAL_COLLECTION = 505
|
||||
"资源收集"
|
||||
TERMINAL_REGULAR = 506
|
||||
"常态事务"
|
||||
TERMINAL_LONGTERM = 507
|
||||
"长期探索"
|
||||
TERMINAL_PERIODIC = 508
|
||||
"周期挑战"
|
||||
OPERATOR_CHOOSE_LEVEL = 601
|
||||
"作战前,关卡未选定"
|
||||
OPERATOR_BEFORE = 602
|
||||
"作战前,关卡已选定"
|
||||
OPERATOR_SELECT = 603
|
||||
"作战前,正在编队"
|
||||
OPERATOR_ONGOING = 604
|
||||
"代理作战"
|
||||
OPERATOR_FINISH = 605
|
||||
"作战结束"
|
||||
OPERATOR_RECOVER_POTION = 607
|
||||
"恢复理智(药剂)"
|
||||
OPERATOR_RECOVER_ORIGINITE = 608
|
||||
"恢复理智(源石)"
|
||||
OPERATOR_DROP = 609
|
||||
"掉落物品详细说明页"
|
||||
OPERATOR_ELIMINATE = 610
|
||||
"剿灭作战前,关卡已选定"
|
||||
OPERATOR_ELIMINATE_FINISH = 611
|
||||
"剿灭作战结束"
|
||||
OPERATOR_GIVEUP = 612
|
||||
"放弃行动"
|
||||
OPERATOR_FAILED = 613
|
||||
"代理作战失败"
|
||||
OPERATOR_ELIMINATE_AGENCY = 614
|
||||
"剿灭代理卡使用确认"
|
||||
OPERATOR_SUPPORT = 615
|
||||
"借助战"
|
||||
OPERATOR_SUPPORT_AGENT = 616
|
||||
"使用助战干员界面"
|
||||
OPERATOR_AGENT_SELECT = 617
|
||||
"作战干员选择界面"
|
||||
OPERATOR_FIGHT = 618
|
||||
"作战中"
|
||||
SHOP_OTHERS = 701
|
||||
"商店其它界面"
|
||||
SHOP_CREDIT = 702
|
||||
"信用交易所"
|
||||
SHOP_CREDIT_CONFIRM = 703
|
||||
"信用交易所兑换确认"
|
||||
SHOP_ASSIST = 704
|
||||
"助战使用次数"
|
||||
SHOP_UNLOCK_SCHEDULE = 705
|
||||
"累计信用消费"
|
||||
SHOP_TOKEN = 706
|
||||
"凭证交易所"
|
||||
SHOP_TRADE_TOKEN = 707
|
||||
"兑换溢出信物"
|
||||
RECRUIT_MAIN = 801
|
||||
"公招主界面"
|
||||
RECRUIT_TAGS = 802
|
||||
"挑选标签时"
|
||||
RECRUIT_AGENT = 803
|
||||
"开包干员展示"
|
||||
REFRESH_TAGS = 804
|
||||
"刷新词条"
|
||||
RA_MAIN = 901
|
||||
"生息演算首页"
|
||||
RA_GUIDE_ENTRANCE = 902
|
||||
"剧情入口:众人会聚之地(后舍)"
|
||||
RA_GUIDE_DIALOG = 903
|
||||
"剧情对话"
|
||||
RA_BATTLE_ENTRANCE = 904
|
||||
"作战入口"
|
||||
RA_BATTLE = 905
|
||||
"作战中"
|
||||
RA_BATTLE_EXIT_CONFIRM = 906
|
||||
"作战退出确认对话框"
|
||||
RA_GUIDE_BATTLE_ENTRANCE = 907
|
||||
"剧情作战入口"
|
||||
RA_BATTLE_COMPLETE = 908
|
||||
"作战结算"
|
||||
RA_MAP = 909
|
||||
"地图"
|
||||
RA_SQUAD_EDIT = 910
|
||||
"作战分队编辑"
|
||||
RA_KITCHEN = 911
|
||||
"烹饪台"
|
||||
RA_GET_ITEM = 912
|
||||
"获得物资"
|
||||
RA_SQUAD_EDIT_DIALOG = 913
|
||||
"作战分队不携带干员确认"
|
||||
RA_DAY_COMPLETE = 914
|
||||
"生息日结算"
|
||||
RA_DAY_DETAIL = 915
|
||||
"当日详细信息"
|
||||
RA_WASTE_TIME_DIALOG = 916
|
||||
"跳过行动确认对话框"
|
||||
RA_PERIOD_COMPLETE = 917
|
||||
"生存周期完成"
|
||||
RA_DELETE_SAVE_DIALOG = 918
|
||||
"存档删除确认"
|
||||
RA_ADVENTURE = 919
|
||||
"奇遇"
|
||||
RA_NOTICE = 920
|
||||
"一张便条"
|
||||
RA_INSUFFICIENT_DRINK = 921
|
||||
"能量饮料不足"
|
||||
RA_SQUAD_ABNORMAL = 922
|
||||
"当前编队中存在异常情况"
|
||||
SSS_MAIN = 1001
|
||||
"保全作战首页"
|
||||
SSS_START = 1002
|
||||
"开始保全作战"
|
||||
SSS_EC = 1003
|
||||
"定向导能元件"
|
||||
SSS_DEVICE = 1004
|
||||
"战术装备配置"
|
||||
SSS_SQUAD = 1005
|
||||
"首批作战小队选任"
|
||||
SSS_DEPLOY = 1006
|
||||
"开始部署"
|
||||
SSS_REDEPLOY = 1007
|
||||
"重新部署"
|
||||
SSS_EXIT_CONFIRM = 1008
|
||||
"结束当前保全作战"
|
||||
SSS_TERMINATED = 1009
|
||||
"保全作战终止"
|
||||
SF_ENTRANCE = 1101
|
||||
"隐秘战线入口"
|
||||
SF_EXIT = 1102
|
||||
"暂离行动"
|
||||
SF_SELECT_TEAM = 1103
|
||||
"选择小队"
|
||||
SF_CONTINUE = 1104
|
||||
"继续前进"
|
||||
SF_SELECT = 1105
|
||||
"选择路线"
|
||||
SF_ACTIONS = 1106
|
||||
"行动选项"
|
||||
SF_RESULT = 1107
|
||||
"行动结果"
|
||||
SF_EVENT = 1108
|
||||
"应对危机事件"
|
||||
SF_TEAM_PASS = 1109
|
||||
"小队通过危机事件"
|
||||
SF_CLICK_ANYWHERE = 1110
|
||||
"点击任意处继续"
|
||||
SF_END = 1111
|
||||
"抵达终点"
|
||||
HEADHUNTING = 1201
|
||||
"干员寻访"
|
||||
DEPOT = 1301
|
||||
"仓库"
|
||||
ACTIVITY_MAIN = 1401
|
||||
"活动主界面"
|
||||
ACTIVITY_CHOOSE_LEVEL = 1402
|
||||
"活动关选择"
|
||||
SIGN_IN_DAILY = 1501
|
||||
"签到活动"
|
||||
MOON_FESTIVAL = 1502
|
||||
"月饼"
|
||||
LOADING = 9998
|
||||
"场景跳转时的等待界面"
|
||||
CONFIRM = 9999
|
||||
"确认对话框"
|
||||
|
||||
|
||||
SceneComment = {
|
||||
-2: "有导航栏的未知场景",
|
||||
-1: "未知",
|
||||
0: "未定义",
|
||||
1: "首页",
|
||||
2: "物资领取确认",
|
||||
3: "公告",
|
||||
4: "邮件信箱",
|
||||
5: "导航栏返回",
|
||||
6: "升级",
|
||||
7: "开包动画",
|
||||
8: "二次确认(未知)",
|
||||
9: "正在提交反馈至神经",
|
||||
10: "网络拨测",
|
||||
11: "退出游戏",
|
||||
12: "检测到有未下载的语音资源",
|
||||
13: "协议更新",
|
||||
14: "说明",
|
||||
15: "首页源石换玉",
|
||||
16: "首页源石换理智",
|
||||
17: "作战剧情",
|
||||
18: "作战剧情",
|
||||
101: "登录页面",
|
||||
102: "登录页面(输入)",
|
||||
103: "登录页面(快速)",
|
||||
104: "登录中",
|
||||
105: "启动",
|
||||
106: "启动界面公告",
|
||||
107: "注册",
|
||||
108: "滑动验证码",
|
||||
109: "B 服登录界面",
|
||||
110: "登录页面(无按钮入口)",
|
||||
111: "游戏适龄提示",
|
||||
112: "产业合作洽谈会",
|
||||
113: "4周年签到",
|
||||
114: "新登陆界面",
|
||||
116: "B服隐私政策提示",
|
||||
201: "基建全局视角",
|
||||
202: "基建待办事项",
|
||||
203: "线索主界面",
|
||||
204: "基建干员进驻总览",
|
||||
205: "基建放大查看",
|
||||
206: "基建干员排班二次确认",
|
||||
207: "干员进驻设施排序界面",
|
||||
208: "副手简报界面",
|
||||
209: "控制中枢界面",
|
||||
210: "干员选择界面",
|
||||
211: "每日线索领取",
|
||||
212: "接收线索",
|
||||
213: "传递线索",
|
||||
214: "线索交流活动汇总",
|
||||
215: "放置线索",
|
||||
216: "技能升级",
|
||||
217: "训练室主界面",
|
||||
218: "技能升级失败",
|
||||
219: "选择技能",
|
||||
220: "技能升级结算",
|
||||
221: "贸易站订单列表",
|
||||
222: "无人机加速对话框",
|
||||
223: "制造站设施列表",
|
||||
224: "离开基建",
|
||||
225: "急速充能",
|
||||
226: "急速充能对话框",
|
||||
227: "制造站产物选择",
|
||||
228: "订单切换选择",
|
||||
229: "产物更改确认",
|
||||
301: "个人名片",
|
||||
302: "好友列表",
|
||||
303: "基建内访问好友",
|
||||
304: "返回好友列表",
|
||||
401: "日常任务",
|
||||
402: "周常任务",
|
||||
403: "见习任务",
|
||||
501: "终端主界面",
|
||||
502: "主题曲",
|
||||
503: "插曲",
|
||||
504: "别传",
|
||||
505: "资源收集",
|
||||
506: "常态事务",
|
||||
507: "长期探索",
|
||||
508: "周期挑战",
|
||||
601: "作战前,关卡未选定",
|
||||
602: "作战前,关卡已选定",
|
||||
603: "作战前,正在编队",
|
||||
604: "代理作战",
|
||||
605: "作战结束",
|
||||
607: "恢复理智(药剂)",
|
||||
608: "恢复理智(源石)",
|
||||
609: "掉落物品详细说明页",
|
||||
610: "剿灭作战前,关卡已选定",
|
||||
611: "剿灭作战结束",
|
||||
612: "放弃行动",
|
||||
613: "代理作战失败",
|
||||
614: "剿灭代理卡使用确认",
|
||||
615: "借助战",
|
||||
616: "使用助战干员界面",
|
||||
617: "作战干员选择界面",
|
||||
618: "作战中",
|
||||
701: "商店其它界面",
|
||||
702: "信用交易所",
|
||||
703: "信用交易所兑换确认",
|
||||
704: "助战使用次数",
|
||||
705: "累计信用消费",
|
||||
706: "凭证交易所",
|
||||
707: "兑换溢出信物",
|
||||
801: "公招主界面",
|
||||
802: "挑选标签时",
|
||||
803: "开包干员展示",
|
||||
804: "刷新词条",
|
||||
901: "生息演算首页",
|
||||
902: "剧情入口:众人会聚之地(后舍)",
|
||||
903: "剧情对话",
|
||||
904: "作战入口",
|
||||
905: "作战中",
|
||||
906: "作战退出确认对话框",
|
||||
907: "剧情作战入口",
|
||||
908: "作战结算",
|
||||
909: "地图",
|
||||
910: "作战分队编辑",
|
||||
911: "烹饪台",
|
||||
912: "获得物资",
|
||||
913: "作战分队不携带干员确认",
|
||||
914: "生息日结算",
|
||||
915: "当日详细信息",
|
||||
916: "跳过行动确认对话框",
|
||||
917: "生存周期完成",
|
||||
918: "存档删除确认",
|
||||
919: "奇遇",
|
||||
920: "一张便条",
|
||||
921: "能量饮料不足",
|
||||
922: "当前编队中存在异常情况",
|
||||
1001: "保全作战首页",
|
||||
1002: "开始保全作战",
|
||||
1003: "定向导能元件",
|
||||
1004: "战术装备配置",
|
||||
1005: "首批作战小队选任",
|
||||
1006: "开始部署",
|
||||
1007: "重新部署",
|
||||
1008: "结束当前保全作战",
|
||||
1009: "保全作战终止",
|
||||
1101: "隐秘战线入口",
|
||||
1102: "暂离行动",
|
||||
1103: "选择小队",
|
||||
1104: "继续前进",
|
||||
1105: "选择路线",
|
||||
1106: "行动选项",
|
||||
1107: "行动结果",
|
||||
1108: "应对危机事件",
|
||||
1109: "小队通过危机事件",
|
||||
1110: "点击任意处继续",
|
||||
1111: "抵达终点",
|
||||
1201: "干员寻访",
|
||||
1301: "仓库",
|
||||
1401: "活动主界面",
|
||||
1402: "活动关选择",
|
||||
1501: "签到活动",
|
||||
1502: "月饼",
|
||||
9998: "场景跳转时的等待界面",
|
||||
9999: "确认对话框",
|
||||
}
|
309
mower/utils/scheduler_task.py
Normal file
309
mower/utils/scheduler_task.py
Normal file
|
@ -0,0 +1,309 @@
|
|||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from mower.utils import config
|
||||
from mower.utils.datetime import the_same_time
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class TaskTypes(Enum):
|
||||
RUN_ORDER = ("run_order", "跑单", 1)
|
||||
FIAMMETTA = ("菲亚梅塔", "肥鸭", 2)
|
||||
SHIFT_OFF = ("shifit_off", "下班", 2)
|
||||
SHIFT_ON = ("shifit_on", "上班", 2)
|
||||
EXHAUST_OFF = ("exhaust_on", "用尽下班", 2)
|
||||
SELF_CORRECTION = ("self_correction", "纠错", 2)
|
||||
CLUE_PARTY = ("Impart", "趴体", 2)
|
||||
MAA_MALL = ("maa_Mall", "MAA信用购物", 2)
|
||||
NOT_SPECIFIC = ("", "空任务", 2)
|
||||
RECRUIT = ("recruit", "公招", 2)
|
||||
SKLAND = ("skland", "森空岛签到", 2)
|
||||
RE_ORDER = ("宿舍排序", "宿舍排序", 2)
|
||||
RELEASE_DORM = ("释放宿舍空位", "释放宿舍空位", 2)
|
||||
REFRESH_TIME = ("强制刷新任务时间", "强制刷新任务时间", 2)
|
||||
SKILL_UPGRADE = ("技能专精", "技能专精", 2)
|
||||
DEPOT = ("仓库扫描", "仓库扫描", 2) # 但是我不会写剩下的
|
||||
|
||||
def __new__(cls, value, display_value, priority):
|
||||
obj = object.__new__(cls)
|
||||
obj._value_ = value
|
||||
obj.display_value = display_value
|
||||
obj.priority = priority
|
||||
return obj
|
||||
|
||||
|
||||
def find_next_task(
|
||||
tasks,
|
||||
compare_time: datetime | None = None,
|
||||
task_type="",
|
||||
compare_type: Literal["<", "=", ">"] = "<",
|
||||
meta_data="",
|
||||
):
|
||||
"""找符合条件的下一个任务
|
||||
|
||||
Args:
|
||||
tasks: 任务列表
|
||||
compare_time: 截止时间
|
||||
"""
|
||||
if compare_type == "=":
|
||||
return next(
|
||||
(
|
||||
e
|
||||
for e in tasks
|
||||
if the_same_time(e.time, compare_time)
|
||||
and (True if task_type == "" else task_type == e.type)
|
||||
and (True if meta_data == "" else meta_data in e.meta_data)
|
||||
),
|
||||
None,
|
||||
)
|
||||
elif compare_type == ">":
|
||||
return next(
|
||||
(
|
||||
e
|
||||
for e in tasks
|
||||
if (True if compare_time is None else e.time > compare_time)
|
||||
and (True if task_type == "" else task_type == e.type)
|
||||
and (True if meta_data == "" else meta_data in e.meta_data)
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
return next(
|
||||
(
|
||||
e
|
||||
for e in tasks
|
||||
if (True if compare_time is None else e.time < compare_time)
|
||||
and (True if task_type == "" else task_type == e.type)
|
||||
and (True if meta_data == "" else meta_data in e.meta_data)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def scheduling(tasks, run_order_delay=5, execution_time=0.75, time_now=None):
|
||||
# execution_time per room
|
||||
if time_now is None:
|
||||
time_now = datetime.now()
|
||||
if len(tasks) > 0:
|
||||
tasks.sort(key=lambda x: x.time)
|
||||
|
||||
# 任务间隔最小时间(5分钟)
|
||||
min_time_interval = timedelta(minutes=run_order_delay)
|
||||
|
||||
# 初始化变量以跟踪上一个优先级0任务和计划执行时间总和
|
||||
last_priority_0_task = None
|
||||
total_execution_time = 0
|
||||
|
||||
# 遍历任务列表
|
||||
for i, task in enumerate(tasks):
|
||||
current_time = time_now
|
||||
# 判定任务堆积,如果第一个任务已经超时,则认为任务堆积
|
||||
if task.type.priority == 1 and current_time > task.time:
|
||||
total_execution_time += (current_time - task.time).total_seconds() / 60
|
||||
|
||||
if task.type.priority == 1:
|
||||
if last_priority_0_task is not None:
|
||||
time_difference = task.time - last_priority_0_task.time
|
||||
if (
|
||||
config.conf.run_order_grandet_mode.enable
|
||||
and time_difference < min_time_interval
|
||||
and time_now < last_priority_0_task.time
|
||||
):
|
||||
logger.info("检测到跑单任务过于接近,准备修正跑单时间")
|
||||
return last_priority_0_task
|
||||
# 更新上一个优先级0任务和总执行时间
|
||||
last_priority_0_task = task
|
||||
total_execution_time = 0
|
||||
else:
|
||||
# 找到下一个优先级0任务的位置
|
||||
next_priority_0_index = -1
|
||||
for j in range(i + 1, len(tasks)):
|
||||
if tasks[j].type.priority == 1:
|
||||
next_priority_0_index = j
|
||||
break
|
||||
# 如果其他任务的总执行时间超过了下一个优先级0任务的执行时间,调整它们的时间
|
||||
if next_priority_0_index > -1:
|
||||
for j in range(i, next_priority_0_index):
|
||||
# 菲亚充能/派对内置3分钟,线索购物内置1分钟
|
||||
task_time = (
|
||||
0
|
||||
if len(tasks[j].plan) > 0
|
||||
and tasks[j].type
|
||||
not in [TaskTypes.FIAMMETTA, TaskTypes.CLUE_PARTY]
|
||||
else (
|
||||
3
|
||||
if tasks[j].type
|
||||
in [TaskTypes.FIAMMETTA, TaskTypes.CLUE_PARTY]
|
||||
else 1
|
||||
)
|
||||
)
|
||||
# 其他任务按照 每个房间*预设执行时间算 默认 45秒
|
||||
estimate_time = (
|
||||
len(tasks[j].plan) * execution_time
|
||||
if task_time == 0
|
||||
else task_time
|
||||
)
|
||||
if (
|
||||
timedelta(minutes=total_execution_time + estimate_time)
|
||||
+ time_now
|
||||
< tasks[j].time
|
||||
):
|
||||
total_execution_time = 0
|
||||
else:
|
||||
total_execution_time += estimate_time
|
||||
if (
|
||||
timedelta(minutes=total_execution_time) + time_now
|
||||
> tasks[next_priority_0_index].time
|
||||
):
|
||||
logger.info("检测到任务可能影响到下次跑单修改任务至跑单之后")
|
||||
logger.debug("||".join([str(t) for t in tasks]))
|
||||
next_priority_0_time = tasks[next_priority_0_index].time
|
||||
for j in range(i, next_priority_0_index):
|
||||
tasks[j].time = next_priority_0_time + timedelta(seconds=1)
|
||||
next_priority_0_time = tasks[j].time
|
||||
logger.debug("||".join([str(t) for t in tasks]))
|
||||
break
|
||||
tasks.sort(key=lambda x: x.time)
|
||||
|
||||
|
||||
def try_add_release_dorm(plan, time, op_data, tasks):
|
||||
if not op_data.config.free_room:
|
||||
return
|
||||
for k, v in plan.items():
|
||||
for name in v:
|
||||
if name != "Current":
|
||||
_idx, __dorm = op_data.get_dorm_by_name(name)
|
||||
if __dorm and __dorm.time < time:
|
||||
add_release_dorm(tasks, op_data, name)
|
||||
|
||||
|
||||
def add_release_dorm(tasks, op_data, name):
|
||||
_idx, __dorm = op_data.get_dorm_by_name(name)
|
||||
if (
|
||||
__dorm.time > datetime.now()
|
||||
and find_next_task(tasks, task_type=TaskTypes.RELEASE_DORM, meta_data=name)
|
||||
is None
|
||||
):
|
||||
_free = op_data.operators[name]
|
||||
if _free.current_room.startswith("dorm"):
|
||||
__plan = {_free.current_room: ["Current"] * 5}
|
||||
__plan[_free.current_room][_free.current_index] = "Free"
|
||||
task = SchedulerTask(
|
||||
time=__dorm.time,
|
||||
task_type=TaskTypes.RELEASE_DORM,
|
||||
task_plan=__plan,
|
||||
meta_data=name,
|
||||
)
|
||||
tasks.append(task)
|
||||
logger.info(name + " 新增释放宿舍任务")
|
||||
logger.debug(str(task))
|
||||
|
||||
|
||||
def check_dorm_ordering(tasks, op_data):
|
||||
# 仅当下班的时候才触发宿舍排序任务
|
||||
plan = op_data.plan
|
||||
if len(tasks) == 0:
|
||||
return
|
||||
if tasks[0].type == TaskTypes.SHIFT_OFF and tasks[0].meta_data == "":
|
||||
extra_plan = {}
|
||||
other_plan = {}
|
||||
working_agent = []
|
||||
for room, v in tasks[0].plan.items():
|
||||
if not room.startswith("dorm"):
|
||||
working_agent.extend(v)
|
||||
for room, v in tasks[0].plan.items():
|
||||
# 非宿舍则不需要清空
|
||||
if room.startswith("dorm"):
|
||||
# 是否检查过vip位置
|
||||
pass_first_free = False
|
||||
for idx, agent in enumerate(v):
|
||||
# 如果当前位置非宿管 且无人员变动(有变动则是下班干员)
|
||||
if "Free" == plan[room][idx].agent and agent == "Current":
|
||||
# 如果高优先不变,则跳过逻辑判定
|
||||
if not pass_first_free:
|
||||
continue
|
||||
current = next(
|
||||
(
|
||||
obj
|
||||
for obj in op_data.operators.values()
|
||||
if obj.current_room == room and obj.current_index == idx
|
||||
),
|
||||
None,
|
||||
)
|
||||
if current:
|
||||
if current.name not in working_agent:
|
||||
v[idx] = current.name
|
||||
else:
|
||||
logger.debug(f"检测到干员{current.name}已经上班")
|
||||
v[idx] = "Free"
|
||||
if room not in extra_plan:
|
||||
extra_plan[room] = copy.deepcopy(v)
|
||||
# 新生成移除任务 --> 换成移除
|
||||
extra_plan[room][idx] = ""
|
||||
if "Free" == plan[room][idx].agent and not pass_first_free:
|
||||
pass_first_free = True
|
||||
else:
|
||||
other_plan[room] = v
|
||||
tasks[0].meta_data = "宿舍排序完成"
|
||||
if extra_plan:
|
||||
for k, v in other_plan.items():
|
||||
del tasks[0].plan[k]
|
||||
extra_plan[k] = v
|
||||
logger.info("新增排序任务任务")
|
||||
task = SchedulerTask(
|
||||
task_plan=extra_plan,
|
||||
time=tasks[0].time - timedelta(seconds=1),
|
||||
task_type=TaskTypes.RE_ORDER,
|
||||
)
|
||||
tasks.insert(0, task)
|
||||
logger.debug(str(task))
|
||||
|
||||
|
||||
def set_type_enum(value):
|
||||
if value is None:
|
||||
return TaskTypes.NOT_SPECIFIC
|
||||
if isinstance(value, TaskTypes):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
for task_type in TaskTypes:
|
||||
if value.upper() == task_type.display_value.upper():
|
||||
return task_type
|
||||
return TaskTypes.NOT_SPECIFIC
|
||||
|
||||
|
||||
class SchedulerTask:
|
||||
time = None
|
||||
type = ""
|
||||
plan = {}
|
||||
meta_data = ""
|
||||
|
||||
def __init__(self, time=None, task_plan={}, task_type="", meta_data=""):
|
||||
if time is None:
|
||||
self.time = datetime.now()
|
||||
else:
|
||||
self.time = time
|
||||
self.plan = task_plan
|
||||
self.type = set_type_enum(task_type)
|
||||
self.meta_data = meta_data
|
||||
|
||||
def format(self, time_offset=0):
|
||||
res = copy.deepcopy(self)
|
||||
res.time += timedelta(hours=time_offset)
|
||||
res.type = res.type.display_value
|
||||
if res.type == "空任务" and res.meta_data:
|
||||
res.type = res.meta_data
|
||||
return res
|
||||
|
||||
def __str__(self):
|
||||
return f"SchedulerTask(time='{self.time}',task_plan={self.plan},task_type={self.type},meta_data='{self.meta_data}')"
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, SchedulerTask):
|
||||
return (
|
||||
self.type == other.type
|
||||
and self.plan == other.plan
|
||||
and the_same_time(self.time, other.time)
|
||||
)
|
||||
return False
|
134
mower/utils/simulator.py
Normal file
134
mower/utils/simulator.py
Normal file
|
@ -0,0 +1,134 @@
|
|||
import subprocess
|
||||
from enum import Enum
|
||||
from os import system
|
||||
|
||||
from mower import __system__
|
||||
from mower.utils import config
|
||||
from mower.utils.csleep import MowerExit, csleep
|
||||
from mower.utils.log import logger
|
||||
|
||||
|
||||
class Simulator_Type(Enum):
|
||||
Nox = "夜神"
|
||||
MuMu12 = "MuMu12"
|
||||
Leidian9 = "雷电9"
|
||||
Waydroid = "Waydroid"
|
||||
ReDroid = "ReDroid"
|
||||
MuMuPro = "MuMuPro"
|
||||
Genymotion = "Genymotion"
|
||||
|
||||
|
||||
def restart_simulator(stop: bool = True, start: bool = True) -> bool:
|
||||
"""重启模拟器
|
||||
|
||||
Args:
|
||||
stop: 停止模拟器
|
||||
start: 启动模拟器
|
||||
|
||||
Returns:
|
||||
是否成功
|
||||
"""
|
||||
data = config.conf.simulator
|
||||
index = data.index
|
||||
simulator_type = data.name
|
||||
simulator_folder = data.simulator_folder
|
||||
wait_time = data.wait_time
|
||||
hotkey = data.hotkey
|
||||
cmd = ""
|
||||
blocking = False
|
||||
|
||||
if simulator_type not in Simulator_Type:
|
||||
logger.warning(f"尚未支持{simulator_type}重启/自动启动")
|
||||
csleep(10)
|
||||
return False
|
||||
|
||||
if simulator_type == Simulator_Type.Nox.value:
|
||||
cmd = "Nox.exe"
|
||||
if int(index) >= 0:
|
||||
cmd += f" -clone:Nox_{index}"
|
||||
cmd += " -quit"
|
||||
elif simulator_type == Simulator_Type.MuMu12.value:
|
||||
cmd = "MuMuManager.exe api -v "
|
||||
if int(index) >= 0:
|
||||
cmd += f"{index} "
|
||||
cmd += "shutdown_player"
|
||||
elif simulator_type == Simulator_Type.Waydroid.value:
|
||||
cmd = "waydroid session stop"
|
||||
elif simulator_type == Simulator_Type.Leidian9.value:
|
||||
cmd = "ldconsole.exe quit --index "
|
||||
if int(index) >= 0:
|
||||
cmd += f"{index} "
|
||||
else:
|
||||
cmd += "0"
|
||||
elif simulator_type == Simulator_Type.ReDroid.value:
|
||||
cmd = f"docker stop {index} -t 0"
|
||||
elif simulator_type == Simulator_Type.MuMuPro.value:
|
||||
cmd = f"Contents/MacOS/mumutool close {index}"
|
||||
elif simulator_type == Simulator_Type.Genymotion.value:
|
||||
if __system__ == "windows":
|
||||
cmd = "gmtool.exe"
|
||||
elif __system__ == "darwin":
|
||||
cmd = "Contents/MacOS/gmtool"
|
||||
else:
|
||||
cmd = "./gmtool"
|
||||
cmd += f' admin stop "{index}"'
|
||||
blocking = True
|
||||
|
||||
if stop:
|
||||
logger.info(f"关闭{simulator_type}模拟器")
|
||||
exec_cmd(cmd, simulator_folder, 3, blocking)
|
||||
if simulator_type == "MuMu12" and config.conf.fix_mumu12_adb_disconnect:
|
||||
logger.info("结束adb进程")
|
||||
system("taskkill /f /t /im adb.exe")
|
||||
|
||||
if start:
|
||||
if simulator_type == Simulator_Type.Nox.value:
|
||||
cmd = cmd.replace(" -quit", "")
|
||||
elif simulator_type == Simulator_Type.MuMu12.value:
|
||||
cmd = cmd.replace(" shutdown_player", " launch_player")
|
||||
elif simulator_type == Simulator_Type.Waydroid.value:
|
||||
cmd = "waydroid show-full-ui"
|
||||
elif simulator_type == Simulator_Type.Leidian9.value:
|
||||
cmd = cmd.replace("quit", "launch")
|
||||
elif simulator_type == Simulator_Type.ReDroid.value:
|
||||
cmd = f"docker start {index}"
|
||||
elif simulator_type == Simulator_Type.MuMuPro.value:
|
||||
cmd = cmd.replace("close", "open")
|
||||
elif simulator_type == Simulator_Type.Genymotion.value:
|
||||
cmd = cmd.replace("stop", "start", 1)
|
||||
logger.info(f"启动{simulator_type}模拟器")
|
||||
exec_cmd(cmd, simulator_folder, wait_time, blocking)
|
||||
if hotkey:
|
||||
hotkey = hotkey.split("+")
|
||||
import pyautogui
|
||||
|
||||
pyautogui.FAILSAFE = False
|
||||
pyautogui.hotkey(*hotkey)
|
||||
return True
|
||||
|
||||
|
||||
def exec_cmd(cmd, folder_path, wait_time, blocking):
|
||||
logger.debug(cmd)
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
cwd=folder_path,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if __system__ == "windows" else 0,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
if not blocking:
|
||||
csleep(wait_time)
|
||||
process.terminate()
|
||||
return
|
||||
while wait_time > 0:
|
||||
try:
|
||||
csleep(0)
|
||||
logger.debug(process.communicate(timeout=1))
|
||||
break
|
||||
except MowerExit:
|
||||
raise
|
||||
except subprocess.TimeoutExpired:
|
||||
wait_time -= 1
|
148
mower/utils/skland.py
Normal file
148
mower/utils/skland.py
Normal file
|
@ -0,0 +1,148 @@
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from urllib import parse
|
||||
|
||||
import requests
|
||||
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.SecuritySm import get_d_id
|
||||
|
||||
app_code = "4ca99fa6b56cc2ba"
|
||||
|
||||
# 签到url
|
||||
sign_url = "https://zonai.skland.com/api/v1/game/attendance"
|
||||
# 绑定的角色url
|
||||
binding_url = "https://zonai.skland.com/api/v1/game/player/binding"
|
||||
# 验证码url
|
||||
login_code_url = "https://as.hypergryph.com/general/v1/send_phone_code"
|
||||
# 验证码登录
|
||||
token_phone_code_url = "https://as.hypergryph.com/user/auth/v2/token_by_phone_code"
|
||||
# 密码登录
|
||||
token_password_url = "https://as.hypergryph.com/user/auth/v1/token_by_phone_password"
|
||||
# 使用token获得认证代码
|
||||
grant_code_url = "https://as.hypergryph.com/user/oauth2/v2/grant"
|
||||
# 使用认证代码获得cred
|
||||
cred_code_url = "https://zonai.skland.com/web/v1/user/auth/generate_cred_by_code"
|
||||
header = {
|
||||
"cred": "",
|
||||
"User-Agent": "Skland/1.0.1 (com.hypergryph.skland; build:100001014; Android 31; ) Okhttp/4.11.0",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Connection": "close",
|
||||
}
|
||||
header_login = {
|
||||
"User-Agent": "Skland/1.0.1 (com.hypergryph.skland; build:100001014; Android 31; ) Okhttp/4.11.0",
|
||||
"Accept-Encoding": "gzip",
|
||||
"Connection": "close",
|
||||
"dId": get_d_id(),
|
||||
}
|
||||
header_for_sign = {"platform": "", "timestamp": "", "dId": "", "vName": ""}
|
||||
|
||||
|
||||
def generate_signature(token: str, path, body_or_query):
|
||||
"""
|
||||
获得签名头
|
||||
接口地址+方法为Get请求?用query否则用body+时间戳+ 请求头的四个重要参数(dId,platform,timestamp,vName).toJSON()
|
||||
将此字符串做HMAC加密,算法为SHA-256,密钥token为请求cred接口会返回的一个token值
|
||||
再将加密后的字符串做MD5即得到sign
|
||||
:param token: 拿cred时候的token
|
||||
:param path: 请求路径(不包括网址)
|
||||
:param body_or_query: 如果是GET,则是它的query。POST则为它的body
|
||||
:return: 计算完毕的sign
|
||||
"""
|
||||
# 总是说请勿修改设备时间,怕不是yj你的服务器有问题吧,所以这里特地-2
|
||||
|
||||
t = str(int(time.time()) - 2)
|
||||
token = token.encode("utf-8")
|
||||
header_ca = json.loads(json.dumps(header_for_sign))
|
||||
header_ca["timestamp"] = t
|
||||
header_ca_str = json.dumps(header_ca, separators=(",", ":"))
|
||||
s = path + body_or_query + t + header_ca_str
|
||||
hex_s = hmac.new(token, s.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
md5 = hashlib.md5(hex_s.encode("utf-8")).hexdigest().encode("utf-8").decode("utf-8")
|
||||
return md5, header_ca
|
||||
|
||||
|
||||
def get_sign_header(url: str, method, body, sign_token, old_header=header):
|
||||
h = json.loads(json.dumps(old_header))
|
||||
p = parse.urlparse(url)
|
||||
if method.lower() == "get":
|
||||
h["sign"], header_ca = generate_signature(sign_token, p.path, p.query)
|
||||
else:
|
||||
h["sign"], header_ca = generate_signature(sign_token, p.path, json.dumps(body))
|
||||
for i in header_ca:
|
||||
h[i] = header_ca[i]
|
||||
return h
|
||||
|
||||
|
||||
def get_grant_code(token):
|
||||
response = requests.post(
|
||||
grant_code_url,
|
||||
json={"appCode": app_code, "token": token, "type": 0},
|
||||
headers=header_login,
|
||||
)
|
||||
resp = response.json()
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"获得认证代码失败:{resp}")
|
||||
if resp.get("status") != 0:
|
||||
raise Exception(f'获得认证代码失败:{resp["msg"]}')
|
||||
return resp["data"]["code"]
|
||||
|
||||
|
||||
def get_cred(grant):
|
||||
"""
|
||||
获取cred
|
||||
:param cred_code_url: 获取cred的URL
|
||||
:param grant: 授权代码
|
||||
:param header_login: 登录请求头
|
||||
:return: cred
|
||||
"""
|
||||
resp = requests.post(
|
||||
cred_code_url, json={"code": grant, "kind": 1}, headers=header_login
|
||||
).json()
|
||||
|
||||
if resp["code"] != 0:
|
||||
raise Exception(f'获得cred失败:{resp["message"]}')
|
||||
|
||||
return resp["data"]
|
||||
|
||||
|
||||
def get_binding_list(sign_token):
|
||||
v = []
|
||||
resp = requests.get(
|
||||
binding_url,
|
||||
headers=get_sign_header(
|
||||
binding_url,
|
||||
"get",
|
||||
None,
|
||||
sign_token,
|
||||
),
|
||||
).json()
|
||||
|
||||
if resp["code"] != 0:
|
||||
logger.info(f"请求角色列表出现问题:{resp['message']}")
|
||||
if resp.get("message") == "用户未登录":
|
||||
logger.warning("用户登录可能失效了,请重新运行此程序!")
|
||||
return []
|
||||
for i in resp["data"]["list"]:
|
||||
if i.get("appCode") != "arknights":
|
||||
continue
|
||||
v.extend(i.get("bindingList"))
|
||||
return v
|
||||
|
||||
|
||||
def get_cred_by_token(token):
|
||||
return get_cred(get_grant_code(token))
|
||||
|
||||
|
||||
def log(account):
|
||||
r = requests.post(
|
||||
token_password_url,
|
||||
json={"phone": account.account, "password": account.password},
|
||||
headers=header_login,
|
||||
).json()
|
||||
if r.get("status") != 0:
|
||||
raise Exception(f'获得token失败:{r["msg"]}')
|
||||
logger.info("森空岛登陆成功")
|
||||
return r["data"]["token"]
|
554
mower/utils/solver.py
Normal file
554
mower/utils/solver.py
Normal file
|
@ -0,0 +1,554 @@
|
|||
import random
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Literal, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from mower.data import scene_list
|
||||
from mower.utils import config
|
||||
from mower.utils import typealias as tp
|
||||
from mower.utils.csleep import MowerExit, csleep
|
||||
from mower.utils.device.adb_client.const import KeyCode
|
||||
from mower.utils.device.adb_client.session import Session
|
||||
from mower.utils.device.device import Device
|
||||
from mower.utils.device.scrcpy import Scrcpy
|
||||
from mower.utils.image import cropimg, thres2
|
||||
from mower.utils.log import logger
|
||||
from mower.utils.recognize import RecognizeError, Recognizer, Scene
|
||||
from mower.utils.simulator import restart_simulator
|
||||
from mower.utils.traceback import caller_info
|
||||
|
||||
|
||||
class StrategyError(Exception):
|
||||
"""Strategy Error"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BaseSolver:
|
||||
"""Base class, provide basic operation"""
|
||||
|
||||
tap_info = None, None
|
||||
waiting_scene = [
|
||||
Scene.CONNECTING,
|
||||
Scene.UNKNOWN,
|
||||
Scene.UNKNOWN_WITH_NAVBAR,
|
||||
Scene.LOADING,
|
||||
Scene.LOGIN_LOADING,
|
||||
Scene.LOGIN_MAIN_NOENTRY,
|
||||
Scene.OPERATOR_ONGOING,
|
||||
]
|
||||
|
||||
def __init__(self) -> None:
|
||||
if config.device is None:
|
||||
while True:
|
||||
try:
|
||||
config.device = Device()
|
||||
config.device.client.check_server_alive()
|
||||
Session().connect()
|
||||
if not config.device.check_device_screen():
|
||||
raise MowerExit
|
||||
if config.conf.droidcast.enable:
|
||||
config.device.start_droidcast()
|
||||
if config.conf.touch_method == "scrcpy":
|
||||
config.device.control.scrcpy = Scrcpy(config.device.client)
|
||||
break
|
||||
except MowerExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
restart_simulator()
|
||||
|
||||
if config.recog is None:
|
||||
config.recog = Recognizer()
|
||||
|
||||
def run(self, last_time=30) -> None:
|
||||
begin_time = datetime.now()
|
||||
self.check_current_focus()
|
||||
# retry_times = config.MAX_RETRYTIME
|
||||
result = None
|
||||
while True:
|
||||
try:
|
||||
if datetime.now() - begin_time > timedelta(minutes=last_time):
|
||||
logger.error("当前任务超时")
|
||||
raise TimeoutError
|
||||
config.recog.update()
|
||||
result = self.transition()
|
||||
if result:
|
||||
return result
|
||||
except RecognizeError as e:
|
||||
logger.exception(f"识别出了点小差错 qwq: {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise e
|
||||
# retry_times = config.MAX_RETRYTIME
|
||||
|
||||
@abstractmethod
|
||||
def transition(self) -> bool:
|
||||
# the change from one state to another is called transition
|
||||
return True # means task completed
|
||||
|
||||
def get_color(self, pos: tp.Coordinate) -> tp.Pixel:
|
||||
"""get the color of the pixel"""
|
||||
return config.recog.color(pos[0], pos[1])
|
||||
|
||||
@staticmethod
|
||||
def get_pos(
|
||||
poly: tp.Location, x_rate: float = 0.5, y_rate: float = 0.5
|
||||
) -> tp.Coordinate:
|
||||
"""get the pos form tp.Location"""
|
||||
if poly is None:
|
||||
raise RecognizeError("poly is empty")
|
||||
elif len(poly) == 4:
|
||||
# tp.Rectangle
|
||||
x = (
|
||||
poly[0][0] * (1 - x_rate)
|
||||
+ poly[1][0] * (1 - x_rate)
|
||||
+ poly[2][0] * x_rate
|
||||
+ poly[3][0] * x_rate
|
||||
) / 2
|
||||
y = (
|
||||
poly[0][1] * (1 - y_rate)
|
||||
+ poly[3][1] * (1 - y_rate)
|
||||
+ poly[1][1] * y_rate
|
||||
+ poly[2][1] * y_rate
|
||||
) / 2
|
||||
elif len(poly) == 2 and isinstance(poly[0], (list, tuple)):
|
||||
# tp.Scope
|
||||
x = poly[0][0] * (1 - x_rate) + poly[1][0] * x_rate
|
||||
y = poly[0][1] * (1 - y_rate) + poly[1][1] * y_rate
|
||||
else:
|
||||
# tp.Coordinate
|
||||
x, y = poly
|
||||
return (int(x), int(y))
|
||||
|
||||
def sleep(self, interval: float = 1) -> None:
|
||||
"""sleeping for a interval"""
|
||||
csleep(interval)
|
||||
config.recog.update()
|
||||
|
||||
def input(self, referent: str, input_area: tp.Scope, text: str = None) -> None:
|
||||
"""input text"""
|
||||
logger.debug(f"{referent=} {input_area=}")
|
||||
config.device.tap(self.get_pos(input_area))
|
||||
time.sleep(0.5)
|
||||
if text is None:
|
||||
text = input(referent).strip()
|
||||
config.device.send_text(str(text))
|
||||
config.device.tap((0, 0))
|
||||
|
||||
def find(
|
||||
self,
|
||||
res: tp.Res,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope = None,
|
||||
thres: int = None,
|
||||
judge: bool = True,
|
||||
strict: bool = False,
|
||||
score=0.0,
|
||||
) -> tp.Scope:
|
||||
return config.recog.find(res, draw, scope, thres, judge, strict, score)
|
||||
|
||||
def tap(
|
||||
self,
|
||||
poly: tp.Location,
|
||||
x_rate: float = 0.5,
|
||||
y_rate: float = 0.5,
|
||||
interval: float = 1,
|
||||
) -> None:
|
||||
"""tap"""
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
self.tap_info = None, None
|
||||
pos = self.get_pos(poly, x_rate, y_rate)
|
||||
config.device.tap(pos)
|
||||
if interval > 0:
|
||||
self.sleep(interval)
|
||||
|
||||
def ctap(
|
||||
self, pos: tp.Location, max_seconds: float = 10, interval: float = 1
|
||||
) -> bool:
|
||||
"""同一处代码多次调用ctap,在max_seconds时长内最多点击一次
|
||||
Args:
|
||||
pos: 点击位置
|
||||
max_seconds: 等待网络连接建议设10,等待动画建议设5或3
|
||||
interval: 点击后sleep的时长
|
||||
Returns:
|
||||
本次点击是否成功
|
||||
"""
|
||||
id = caller_info()
|
||||
now = datetime.now()
|
||||
lid, ltime = self.tap_info
|
||||
if lid != id or (lid == id and now - ltime > timedelta(seconds=max_seconds)):
|
||||
logger.debug(f"tap {id}")
|
||||
self.tap(pos, interval=interval)
|
||||
self.tap_info = id, now
|
||||
return True
|
||||
else:
|
||||
self.sleep(interval)
|
||||
logger.debug(f"skip {id}")
|
||||
return False
|
||||
|
||||
def check_current_focus(self):
|
||||
config.recog.check_current_focus()
|
||||
|
||||
def restart_game(self):
|
||||
"重启游戏"
|
||||
config.device.exit()
|
||||
config.device.launch()
|
||||
config.recog.update()
|
||||
|
||||
def tap_element(
|
||||
self,
|
||||
element_name: tp.Res,
|
||||
x_rate: float = 0.5,
|
||||
y_rate: float = 0.5,
|
||||
interval: float = 1,
|
||||
score: float = 0.0,
|
||||
draw: bool = False,
|
||||
scope: tp.Scope = None,
|
||||
judge: bool = True,
|
||||
detected: bool = False,
|
||||
thres: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""tap element"""
|
||||
element = self.find(
|
||||
element_name, draw, scope, judge=judge, score=score, thres=thres
|
||||
)
|
||||
if detected and element is None:
|
||||
return False
|
||||
self.tap(element, x_rate, y_rate, interval)
|
||||
return True
|
||||
|
||||
def tap_index_element(
|
||||
self,
|
||||
name: Literal[
|
||||
"friend",
|
||||
"infrastructure",
|
||||
"mission",
|
||||
"recruit",
|
||||
"shop",
|
||||
"terminal",
|
||||
"warehouse",
|
||||
"headhunting",
|
||||
"mail",
|
||||
],
|
||||
):
|
||||
pos = {
|
||||
"friend": (544, 862), # 好友
|
||||
"infrastructure": (1545, 948), # 基建
|
||||
"mission": (1201, 904), # 任务
|
||||
"recruit": (1507, 774), # 公开招募
|
||||
"shop": (1251, 727), # 采购中心
|
||||
"terminal": (1458, 297), # 终端
|
||||
"warehouse": (1788, 973), # 仓库
|
||||
"headhunting": (1749, 783), # 干员寻访
|
||||
"mail": (292, 62), # 邮件
|
||||
}
|
||||
self.ctap(pos[name])
|
||||
|
||||
def tap_nav_element(
|
||||
self,
|
||||
name: Literal[
|
||||
"index",
|
||||
"terminal",
|
||||
"infrastructure",
|
||||
"recruit",
|
||||
"headhunting",
|
||||
"shop",
|
||||
"mission",
|
||||
"friend",
|
||||
],
|
||||
):
|
||||
pos = {
|
||||
"index": (140, 365), # 首页
|
||||
"terminal": (793, 163), # 终端
|
||||
"infrastructure": (1030, 163), # 基建
|
||||
"recruit": (1435, 365), # 公开招募
|
||||
"headhunting": (1623, 364), # 干员寻访
|
||||
"shop": (1804, 362), # 采购中心
|
||||
"mission": (1631, 53), # 任务
|
||||
"friend": (1801, 53), # 好友
|
||||
}
|
||||
self.ctap(pos[name])
|
||||
|
||||
def tap_terminal_button(
|
||||
self,
|
||||
name: Literal[
|
||||
"main",
|
||||
"main_theme",
|
||||
"intermezzi",
|
||||
"biography",
|
||||
"collection",
|
||||
"regular",
|
||||
"longterm",
|
||||
"contract",
|
||||
],
|
||||
):
|
||||
y = 1005
|
||||
pos = {
|
||||
"main": (115, y), # 首页
|
||||
"main_theme": (356, y), # 主题曲
|
||||
"intermezzi": (596, y), # 插曲
|
||||
"biography": (836, y), # 别传
|
||||
"collection": (1077, y), # 资源收集
|
||||
"regular": (1317, y), # 常态事务
|
||||
"longterm": (1556, y), # 长期探索
|
||||
"contract": (1796, y), # 危机合约
|
||||
}
|
||||
self.ctap(pos[name])
|
||||
|
||||
def switch_shop(
|
||||
self,
|
||||
name: Literal[
|
||||
"recommend", "originite", "bundle", "skin", "token", "furniture", "credit"
|
||||
],
|
||||
):
|
||||
y = 165
|
||||
pos = {
|
||||
"recommend": (150, y), # 可露希尔推荐
|
||||
"originite": (425, y), # 源石交易所
|
||||
"bundle": (700, y), # 组合包
|
||||
"skin": (970, y), # 时装商店
|
||||
"token": (1250, y), # 凭证交易所
|
||||
"furniture": (1520, y), # 家具商店
|
||||
"credit": (1805, y), # 信用交易所
|
||||
}
|
||||
self.tap(pos[name])
|
||||
|
||||
def template_match(
|
||||
self,
|
||||
res: str,
|
||||
scope: Optional[tp.Scope] = None,
|
||||
method: int = cv2.TM_CCOEFF_NORMED,
|
||||
) -> Tuple[float, tp.Scope]:
|
||||
return config.recog.template_match(res, scope, method)
|
||||
|
||||
def swipe(
|
||||
self,
|
||||
start: tp.Coordinate,
|
||||
movement: tp.Coordinate,
|
||||
duration: int = 100,
|
||||
interval: float = 1,
|
||||
) -> None:
|
||||
"""swipe"""
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
end = (start[0] + movement[0], start[1] + movement[1])
|
||||
config.device.swipe(start, end, duration=duration)
|
||||
if interval > 0:
|
||||
self.sleep(interval)
|
||||
|
||||
def swipe_only(
|
||||
self,
|
||||
start: tp.Coordinate,
|
||||
movement: tp.Coordinate,
|
||||
duration: int = 100,
|
||||
interval: float = 1,
|
||||
) -> None:
|
||||
"""swipe only, no rebuild and recapture"""
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
end = (start[0] + movement[0], start[1] + movement[1])
|
||||
config.device.swipe(start, end, duration=duration)
|
||||
if interval > 0:
|
||||
csleep(interval)
|
||||
|
||||
# def swipe_seq(self, points: list[tp.Coordinate], duration: int = 100, interval: float = 1, rebuild: bool = True) -> None:
|
||||
# """ swipe with point sequence """
|
||||
# config.device.swipe(points, duration=duration)
|
||||
# if interval > 0:
|
||||
# self.sleep(interval, rebuild)
|
||||
|
||||
# def swipe_move(self, start: tp.Coordinate, movements: list[tp.Coordinate], duration: int = 100, interval: float = 1, rebuild: bool = True) -> None:
|
||||
# """ swipe with start and movement sequence """
|
||||
# points = [start]
|
||||
# for move in movements:
|
||||
# points.append((points[-1][0] + move[0], points[-1][1] + move[1]))
|
||||
# config.device.swipe(points, duration=duration)
|
||||
# if interval > 0:
|
||||
# self.sleep(interval, rebuild)
|
||||
|
||||
def swipe_noinertia(
|
||||
self,
|
||||
start: tp.Coordinate,
|
||||
movement: tp.Coordinate,
|
||||
duration: int = 20,
|
||||
interval: float = 0.2,
|
||||
) -> None:
|
||||
"""swipe with no inertia (movement should be vertical)"""
|
||||
if config.stop_mower.is_set():
|
||||
raise MowerExit
|
||||
points = [start]
|
||||
if movement[0] == 0:
|
||||
dis = abs(movement[1])
|
||||
points.append((start[0] + 100, start[1]))
|
||||
points.append((start[0] + 100, start[1] + movement[1]))
|
||||
points.append((start[0], start[1] + movement[1]))
|
||||
else:
|
||||
dis = abs(movement[0])
|
||||
points.append((start[0], start[1] + 100))
|
||||
points.append((start[0] + movement[0], start[1] + 100))
|
||||
points.append((start[0] + movement[0], start[1]))
|
||||
config.device.swipe_ext(points, durations=[200, dis * duration // 100, 200])
|
||||
if interval > 0:
|
||||
self.sleep(interval)
|
||||
|
||||
def back(self, interval: float = 1) -> None:
|
||||
"""send back keyevent"""
|
||||
self.tap_info = None, None
|
||||
config.device.send_keyevent(KeyCode.KEYCODE_BACK)
|
||||
self.sleep(interval)
|
||||
|
||||
def cback(self, max_seconds: float = 10, interval: float = 1) -> bool:
|
||||
"""同一处代码多次调用cback,在max_seconds时长内最多返回一次
|
||||
Args:
|
||||
max_seconds: 等待网络连接建议设10,等待动画建议设5或3
|
||||
interval: 点击后sleep的时长
|
||||
Returns:
|
||||
本次点击是否成功
|
||||
"""
|
||||
id = caller_info()
|
||||
now = datetime.now()
|
||||
lid, ltime = self.tap_info
|
||||
if lid != id or (lid == id and now - ltime > timedelta(seconds=max_seconds)):
|
||||
logger.debug(f"tap {id}")
|
||||
self.back(interval=interval)
|
||||
self.tap_info = id, now
|
||||
return True
|
||||
else:
|
||||
self.sleep(interval)
|
||||
logger.debug(f"skip {id}")
|
||||
return False
|
||||
|
||||
def scene(self) -> int:
|
||||
"""get the current scene in the game"""
|
||||
return config.recog.get_scene()
|
||||
|
||||
def ra_scene(self) -> int:
|
||||
"""
|
||||
生息演算场景识别
|
||||
"""
|
||||
return config.recog.get_ra_scene()
|
||||
|
||||
def sf_scene(self) -> int:
|
||||
"""
|
||||
隐秘战线场景识别
|
||||
"""
|
||||
return config.recog.get_sf_scene()
|
||||
|
||||
def train_scene(self) -> int:
|
||||
"""
|
||||
训练室景识别
|
||||
"""
|
||||
return config.recog.get_train_scene()
|
||||
|
||||
def solve_captcha(self, refresh=False):
|
||||
th = thres2(config.recog.gray, 254)
|
||||
pos = np.nonzero(th)
|
||||
offset_x = pos[1].min()
|
||||
offset_y = pos[0].min()
|
||||
img_scope = ((offset_x, offset_y), (pos[1].max(), pos[0].max()))
|
||||
img = cropimg(config.recog.img, img_scope)
|
||||
h = img.shape[0]
|
||||
|
||||
def _t(ratio):
|
||||
return int(h * ratio)
|
||||
|
||||
def _p(ratio_x, ratio_y):
|
||||
return _t(ratio_x), _t(ratio_y)
|
||||
|
||||
if refresh:
|
||||
logger.info("刷新验证码")
|
||||
self.tap((offset_x + _t(0.189), offset_y + _t(0.916)), interval=3)
|
||||
img = cropimg(config.recog.img, img_scope)
|
||||
|
||||
left_part = cropimg(img, (_p(0.032, 0.032), _p(0.202, 0.591)))
|
||||
hsv = cv2.cvtColor(left_part, cv2.COLOR_RGB2HSV)
|
||||
mask = cv2.inRange(hsv, (25, 0, 0), (35, 255, 255))
|
||||
|
||||
tpl_width = _t(0.148)
|
||||
tpl_height = _t(0.135)
|
||||
tpl_border = _t(0.0056)
|
||||
tpl_padding = _t(0.018)
|
||||
tpl = np.zeros((tpl_height, tpl_width), np.uint8)
|
||||
tpl[:] = (255,)
|
||||
tpl[
|
||||
tpl_border : tpl_height - tpl_border,
|
||||
tpl_border : tpl_width - tpl_border,
|
||||
] = (0,)
|
||||
|
||||
result = cv2.matchTemplate(mask, tpl, cv2.TM_SQDIFF, None, tpl)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
x, y = min_loc
|
||||
|
||||
source_p = (
|
||||
(x + tpl_padding, y + tpl_padding),
|
||||
(x + tpl_width - tpl_padding, y + tpl_height - tpl_padding),
|
||||
)
|
||||
source = cropimg(left_part, source_p)
|
||||
mask = cropimg(mask, source_p)
|
||||
right_part = cropimg(
|
||||
img,
|
||||
(
|
||||
(_t(0.201), _t(0.032) + source_p[0][1]),
|
||||
(_t(0.94), _t(0.032) + source_p[1][1]),
|
||||
),
|
||||
)
|
||||
|
||||
for _y in range(source.shape[0]):
|
||||
for _x in range(source.shape[1]):
|
||||
for _c in range(source.shape[2]):
|
||||
source[_y, _x, _c] = np.clip(source[_y, _x, _c] * 0.7 - 23, 0, 255)
|
||||
|
||||
mask = cv2.bitwise_not(mask)
|
||||
result = cv2.matchTemplate(right_part, source, cv2.TM_SQDIFF_NORMED, None, mask)
|
||||
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)
|
||||
x = _t(0.201) + min_loc[0] - _t(0.032) - x - tpl_padding + _t(0.128)
|
||||
x += random.choice([-4, -3, -2, 2, 3, 4])
|
||||
|
||||
def _rb(R, r):
|
||||
return random.random() * _t(R) + _t(r)
|
||||
|
||||
btn_x = _rb(0.1, 0.01)
|
||||
start = offset_x + btn_x + _t(0.128), offset_y + _rb(0.1, 0.01) + _t(0.711)
|
||||
end = offset_x + btn_x + x, offset_y + _rb(0.1, 0.01) + _t(0.711)
|
||||
p1 = end[0] + _rb(0.1, 0.02), end[1] + _rb(0.05, 0.02)
|
||||
p2 = end[0] + _rb(0.1, 0.02), end[1] + _rb(0.05, 0.02)
|
||||
|
||||
logger.info("滑动验证码")
|
||||
config.device.swipe_ext(
|
||||
(start, p1, p2, end, end),
|
||||
durations=[
|
||||
random.randint(400, 600),
|
||||
random.randint(200, 300),
|
||||
random.randint(200, 300),
|
||||
random.randint(200, 300),
|
||||
],
|
||||
)
|
||||
|
||||
def waiting_solver(self):
|
||||
"""需要等待的页面解决方法。UNKNOWN_WITH_NAVBAR超时直接返回False,其他超时重启返回False"""
|
||||
scene = self.scene()
|
||||
start_time = datetime.now()
|
||||
sleep_time, wait_time = getattr(
|
||||
config.conf.waiting_scene_v2, scene_list[str(scene)]["label"]
|
||||
)
|
||||
stop_time = start_time + timedelta(seconds=wait_time)
|
||||
while datetime.now() < stop_time:
|
||||
self.sleep(sleep_time / 1000)
|
||||
if self.scene() != scene:
|
||||
return True
|
||||
if scene == Scene.UNKNOWN_WITH_NAVBAR:
|
||||
logger.debug("有导航栏的未知场景超时")
|
||||
return False
|
||||
else:
|
||||
logger.warning("相同场景等待超时")
|
||||
config.device.exit()
|
||||
csleep(3)
|
||||
self.check_current_focus()
|
||||
return False
|
236
mower/utils/tile_pos.py
Normal file
236
mower/utils/tile_pos.py
Normal file
|
@ -0,0 +1,236 @@
|
|||
import lzma
|
||||
import math
|
||||
import pickle
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from mower import __rootdir__
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tile:
|
||||
heightType: int
|
||||
buildableType: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Vector3:
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
def clone(self) -> "Vector3":
|
||||
return Vector3(self.x, self.y, self.z)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Vector2:
|
||||
x: float
|
||||
y: float
|
||||
|
||||
def clone(self) -> "Vector2":
|
||||
return Vector2(self.x, self.y)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Level:
|
||||
stageId: str
|
||||
code: str
|
||||
levelId: str
|
||||
name: str
|
||||
height: int
|
||||
width: int
|
||||
tiles: List[List[Tile]] = None
|
||||
view: List[List[int]] = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_data: dict[Any, Any]) -> "Level":
|
||||
raw_tiles = json_data["tiles"]
|
||||
tiles = []
|
||||
for row in raw_tiles:
|
||||
row_tiles = []
|
||||
for tile in row:
|
||||
row_tiles.append(Tile(tile["heightType"], tile["buildableType"]))
|
||||
tiles.append(row_tiles)
|
||||
|
||||
return cls(
|
||||
stageId=json_data["stageId"],
|
||||
code=json_data["code"],
|
||||
levelId=json_data["levelId"],
|
||||
name=json_data["name"],
|
||||
height=json_data["height"],
|
||||
width=json_data["width"],
|
||||
tiles=tiles,
|
||||
view=json_data["view"],
|
||||
)
|
||||
|
||||
def get_width(self):
|
||||
return self.width
|
||||
|
||||
def get_height(self):
|
||||
return self.height
|
||||
|
||||
def get_tile(self, row: int, col: int) -> Optional[Tile]:
|
||||
if 0 <= row <= self.height and 0 <= col <= self.width:
|
||||
return self.tiles[row][col]
|
||||
return None
|
||||
|
||||
|
||||
class Calc:
|
||||
screen_width: int
|
||||
screen_height: int
|
||||
ratio: float
|
||||
|
||||
view: Vector3
|
||||
view_side: Vector3
|
||||
level: Level
|
||||
|
||||
matrix_p: npt.NDArray[np.float32]
|
||||
matrix_x: npt.NDArray[np.float32]
|
||||
matrix_y: npt.NDArray[np.float32]
|
||||
|
||||
def __init__(self, screen_width: int, screen_height: int, level: Level):
|
||||
self.screen_width = screen_width
|
||||
self.screen_height = screen_height
|
||||
self.ratio = screen_height / screen_width
|
||||
self.level = level
|
||||
self.matrix_p = np.array(
|
||||
[
|
||||
[self.ratio / math.tan(math.pi * 20 / 180), 0, 0, 0],
|
||||
[0, 1 / math.tan(math.pi * 20 / 180), 0, 0],
|
||||
[0, 0, -(1000 + 0.3) / (1000 - 0.3), -(1000 * 0.3 * 2) / (1000 - 0.3)],
|
||||
[0, 0, -1, 0],
|
||||
]
|
||||
)
|
||||
self.matrix_x = np.array(
|
||||
[
|
||||
[1, 0, 0, 0],
|
||||
[0, math.cos(math.pi * 30 / 180), -math.sin(math.pi * 30 / 180), 0],
|
||||
[0, -math.sin(math.pi * 30 / 180), -math.cos(math.pi * 30 / 180), 0],
|
||||
[0, 0, 0, 1],
|
||||
]
|
||||
)
|
||||
self.matrix_y = np.array(
|
||||
[
|
||||
[math.cos(math.pi * 10 / 180), 0, math.sin(math.pi * 10 / 180), 0],
|
||||
[0, 1, 0, 0],
|
||||
[-math.sin(math.pi * 10 / 180), 0, math.cos(math.pi * 10 / 180), 0],
|
||||
[0, 0, 0, 1],
|
||||
]
|
||||
)
|
||||
self.view = Vector3(level.view[0][0], level.view[0][1], level.view[0][2])
|
||||
self.view_side = Vector3(level.view[1][0], level.view[1][1], level.view[1][2])
|
||||
|
||||
def adapter(self) -> Tuple[float, float]:
|
||||
fromRatio = 9 / 16
|
||||
toRatio = 3 / 4
|
||||
if self.ratio < fromRatio - 0.00001:
|
||||
return 0, 0
|
||||
t = (self.ratio - fromRatio) / (toRatio - fromRatio)
|
||||
return -1.4 * t, -2.8 * t
|
||||
|
||||
def get_focus_offset(self, tile_x: int, tile_y: int) -> Vector3:
|
||||
x = tile_x - (self.level.width - 1) / 2
|
||||
y = (self.level.height - 1) / 2 - tile_y
|
||||
return Vector3(x, y, 0)
|
||||
|
||||
def get_character_world_pos(self, tile_x: int, tile_y: int) -> Vector3:
|
||||
x = tile_x - (self.level.width - 1) / 2
|
||||
y = (self.level.height - 1) / 2 - tile_y
|
||||
tile = self.level.get_tile(tile_y, tile_x)
|
||||
assert tile is not None
|
||||
z = tile.heightType * -0.4
|
||||
return Vector3(x, y, z)
|
||||
|
||||
def get_with_draw_world_pos(self, tile_x: int, tile_y: int) -> Vector3:
|
||||
ret = self.get_character_world_pos(tile_x, tile_y)
|
||||
ret.x -= 1.3143386840820312
|
||||
ret.y += 1.314337134361267
|
||||
ret.z = -0.3967874050140381
|
||||
return ret
|
||||
|
||||
def get_skill_world_pos(self, tile_x: int, tile_y: int) -> Vector3:
|
||||
ret = self.get_character_world_pos(tile_x, tile_y)
|
||||
ret.x += 1.3143386840820312
|
||||
ret.y -= 1.314337134361267
|
||||
ret.z = -0.3967874050140381
|
||||
return ret
|
||||
|
||||
def get_character_screen_pos(
|
||||
self, tile_x: int, tile_y: int, side: bool = False, focus: bool = False
|
||||
) -> Vector2:
|
||||
if focus:
|
||||
side = True
|
||||
world_pos = self.get_character_world_pos(tile_x, tile_y)
|
||||
if focus:
|
||||
offset = self.get_focus_offset(tile_x, tile_y)
|
||||
else:
|
||||
offset = Vector3(0.0, 0.0, 0.0)
|
||||
return self.world_to_screen_pos(world_pos, side, offset)
|
||||
|
||||
def get_with_draw_screen_pos(self, tile_x: int, tile_y: int) -> Vector2:
|
||||
world_pos = self.get_with_draw_world_pos(tile_x, tile_y)
|
||||
offset = self.get_focus_offset(tile_x, tile_y)
|
||||
return self.world_to_screen_pos(world_pos, True, offset)
|
||||
|
||||
def get_skill_screen_pos(self, tile_x: int, tile_y: int) -> Vector2:
|
||||
world_pos = self.get_skill_world_pos(tile_x, tile_y)
|
||||
offset = self.get_focus_offset(tile_x, tile_y)
|
||||
return self.world_to_screen_pos(world_pos, True, offset)
|
||||
|
||||
def world_to_screen_matrix(
|
||||
self, side: bool = False, offset: Optional[Vector3] = None
|
||||
) -> npt.NDArray[np.float32]:
|
||||
if offset is None:
|
||||
offset = Vector3(0.0, 0.0, 0.0)
|
||||
adapter_y, adapter_z = self.adapter()
|
||||
if side:
|
||||
x, y, z = self.view_side.x, self.view_side.y, self.view_side.z
|
||||
else:
|
||||
x, y, z = self.view.x, self.view.y, self.view.z
|
||||
x += offset.x
|
||||
y += offset.y + adapter_y
|
||||
z += offset.z + adapter_z
|
||||
raw = np.array(
|
||||
[
|
||||
[1, 0, 0, -x],
|
||||
[0, 1, 0, -y],
|
||||
[0, 0, 1, -z],
|
||||
[0, 0, 0, 1],
|
||||
],
|
||||
np.float32,
|
||||
)
|
||||
if side:
|
||||
matrix = np.dot(self.matrix_x, self.matrix_y)
|
||||
matrix = np.dot(matrix, raw)
|
||||
else:
|
||||
matrix = np.dot(self.matrix_x, raw)
|
||||
return np.dot(self.matrix_p, matrix)
|
||||
|
||||
def world_to_screen_pos(
|
||||
self, pos: Vector3, side: bool = False, offset: Optional[Vector3] = None
|
||||
) -> Vector2:
|
||||
matrix = self.world_to_screen_matrix(side, offset)
|
||||
x, y, _, w = np.dot(matrix, np.array([pos.x, pos.y, pos.z, 1]))
|
||||
x = (1 + x / w) / 2
|
||||
y = (1 + y / w) / 2
|
||||
return Vector2(x * self.screen_width, (1 - y) * self.screen_height)
|
||||
|
||||
|
||||
LEVELS: List[Level] = []
|
||||
with lzma.open(f"{__rootdir__}/models/levels.pkl", "rb") as f:
|
||||
level_table = pickle.load(f)
|
||||
for data in level_table:
|
||||
LEVELS.append(Level.from_json(data))
|
||||
|
||||
|
||||
def find_level(code: Optional[str], name: Optional[str]) -> Optional[Level]:
|
||||
for level in LEVELS:
|
||||
if code is not None and code == level.code:
|
||||
return level
|
||||
if name is not None and name == level.name:
|
||||
return level
|
||||
return None
|
14
mower/utils/traceback.py
Normal file
14
mower/utils/traceback.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
from inspect import getframeinfo, stack
|
||||
from pathlib import Path
|
||||
|
||||
from mower.utils.path import get_path
|
||||
|
||||
|
||||
def caller_info():
|
||||
caller = getframeinfo(stack()[2][0])
|
||||
relative_name = Path(caller.filename)
|
||||
try:
|
||||
relative_name = relative_name.relative_to(get_path("@install"))
|
||||
except ValueError:
|
||||
pass
|
||||
return f"{relative_name}:{caller.lineno}"
|
16
mower/utils/translate_room.py
Normal file
16
mower/utils/translate_room.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
def translate_room(room):
|
||||
translations = {
|
||||
"room": lambda parts: f"B{parts[1]}0{parts[2]}",
|
||||
"dormitory": lambda parts: f"{parts[1]}层宿舍",
|
||||
"contact": lambda parts: "办公室",
|
||||
"central": lambda parts: "控制中枢",
|
||||
"factory": lambda parts: "加工站",
|
||||
"meeting": lambda parts: "会客室",
|
||||
}
|
||||
|
||||
for keyword, translation_func in translations.items():
|
||||
if keyword in room:
|
||||
parts = room.split("_")
|
||||
return translation_func(parts)
|
||||
|
||||
return room
|
36
mower/utils/typealias/__init__.py
Normal file
36
mower/utils/typealias/__init__.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
from typing import Dict, List, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from .res import Res
|
||||
|
||||
__all__ = ["Res"]
|
||||
|
||||
# Image
|
||||
Image = NDArray[np.int8]
|
||||
Pixel = NDArray[np.int8]
|
||||
|
||||
GrayImage = NDArray[np.int8]
|
||||
GrayPixel = int
|
||||
|
||||
# Recognizer
|
||||
Range = Tuple[int, int]
|
||||
Coordinate = Tuple[int, int]
|
||||
Scope = Tuple[Coordinate, Coordinate]
|
||||
Slice = Tuple[Range, Range]
|
||||
Rectangle = Tuple[Coordinate, Coordinate, Coordinate, Coordinate]
|
||||
Location = Union[Rectangle, Scope, Coordinate]
|
||||
|
||||
# Matcher
|
||||
Hash = List[int]
|
||||
Score = Tuple[float, float, float, float]
|
||||
|
||||
# Operation Plan
|
||||
OpePlan = Tuple[str, int]
|
||||
|
||||
# BaseConstruct Plan
|
||||
BasePlan = Dict[str, List[str]]
|
||||
|
||||
# Parameter
|
||||
ParamArgs = List[str]
|
479
mower/utils/typealias/res.py
Normal file
479
mower/utils/typealias/res.py
Normal file
|
@ -0,0 +1,479 @@
|
|||
from typing import Literal
|
||||
|
||||
Res = Literal[
|
||||
"12cadpa",
|
||||
"1800",
|
||||
"all_in",
|
||||
"announcement_close",
|
||||
"arrange_blue_yes",
|
||||
"arrange_check_in",
|
||||
"arrange_check_in_on",
|
||||
"arrange_confirm",
|
||||
"arrange_order_options",
|
||||
"arrange_order_options_scene",
|
||||
"bill_accelerate",
|
||||
"biography",
|
||||
"business_card",
|
||||
"choose_agent/battle_confirm",
|
||||
"choose_agent/battle_empty",
|
||||
"choose_agent/clear",
|
||||
"choose_agent/clear_battle",
|
||||
"choose_agent/confirm",
|
||||
"choose_agent/empty_skill_slot",
|
||||
"choose_agent/fast_select",
|
||||
"choose_agent/foldup",
|
||||
"choose_agent/open_profession",
|
||||
"choose_agent/perfer",
|
||||
"choose_agent/perfer_agent",
|
||||
"choose_agent/perfer_choosed",
|
||||
"choose_agent/profession/ALL",
|
||||
"choose_agent/profession/CASTER",
|
||||
"choose_agent/profession/choose_arrow",
|
||||
"choose_agent/profession/MEDIC",
|
||||
"choose_agent/profession/PIONEER",
|
||||
"choose_agent/profession/skill",
|
||||
"choose_agent/profession/SNIPER",
|
||||
"choose_agent/profession/SPECIAL",
|
||||
"choose_agent/profession/SUPPORT",
|
||||
"choose_agent/profession/TANK",
|
||||
"choose_agent/profession/WARRIOR",
|
||||
"choose_agent/rect",
|
||||
"choose_agent/riic_empty",
|
||||
"choose_agent/support_skill_be_choosen",
|
||||
"choose_agent/support_status",
|
||||
"choose_agent/trigger",
|
||||
"choose_product_options",
|
||||
"clue/1",
|
||||
"clue/2",
|
||||
"clue/3",
|
||||
"clue/4",
|
||||
"clue/5",
|
||||
"clue/6",
|
||||
"clue/7",
|
||||
"clue/badge_new",
|
||||
"clue/button_get",
|
||||
"clue/button_unlock",
|
||||
"clue/daily",
|
||||
"clue/filter_all",
|
||||
"clue/give_away",
|
||||
"clue/icon_notification",
|
||||
"clue/label_give_away",
|
||||
"clue/receive",
|
||||
"clue/summary",
|
||||
"clue/title_party",
|
||||
"clue",
|
||||
"clue_next",
|
||||
"collection",
|
||||
"confirm",
|
||||
"confirm_blue",
|
||||
"connecting",
|
||||
"control_central",
|
||||
"control_central_assistants",
|
||||
"credit_visiting",
|
||||
"depot",
|
||||
"depot_empty",
|
||||
"depot_num/digit_0",
|
||||
"depot_num/digit_1",
|
||||
"depot_num/digit_2",
|
||||
"depot_num/digit_3",
|
||||
"depot_num/digit_4",
|
||||
"depot_num/digit_5",
|
||||
"depot_num/digit_6",
|
||||
"depot_num/digit_7",
|
||||
"depot_num/digit_8",
|
||||
"depot_num/digit_9",
|
||||
"depot_num/digit_91",
|
||||
"depot_num/digit_point",
|
||||
"double_confirm/exit",
|
||||
"double_confirm/friend",
|
||||
"double_confirm/give_up",
|
||||
"double_confirm/infrastructure",
|
||||
"double_confirm/main",
|
||||
"double_confirm/network",
|
||||
"double_confirm/product_plan",
|
||||
"double_confirm/recruit",
|
||||
"double_confirm/sss",
|
||||
"double_confirm/voice",
|
||||
"drone",
|
||||
"drone_count/0",
|
||||
"drone_count/1",
|
||||
"drone_count/2",
|
||||
"drone_count/3",
|
||||
"drone_count/4",
|
||||
"drone_count/5",
|
||||
"drone_count/6",
|
||||
"drone_count/7",
|
||||
"drone_count/8",
|
||||
"drone_count/9",
|
||||
"episode",
|
||||
"factory_accelerate",
|
||||
"factory_collect",
|
||||
"fight/c",
|
||||
"fight/c_mask",
|
||||
"fight/choose",
|
||||
"fight/collection",
|
||||
"fight/collection_on",
|
||||
"fight/complete",
|
||||
"fight/elite1",
|
||||
"fight/elite2",
|
||||
"fight/enemy",
|
||||
"fight/failed_text",
|
||||
"fight/gear",
|
||||
"fight/kills_separator",
|
||||
"fight/light",
|
||||
"fight/pause",
|
||||
"fight/profession_not_be_choosen",
|
||||
"fight/refresh",
|
||||
"fight/skill_be_choosen",
|
||||
"fight/skill_ready",
|
||||
"fight/skill_stop",
|
||||
"fight/use",
|
||||
"friend_list",
|
||||
"friend_visit",
|
||||
"hypergryph",
|
||||
"icon_notification_black",
|
||||
"index_nav",
|
||||
"infra_collect_bill",
|
||||
"infra_collect_factory",
|
||||
"infra_collect_trust",
|
||||
"infra_complete/信用",
|
||||
"infra_complete/先锋双芯片",
|
||||
"infra_complete/医疗双芯片",
|
||||
"infra_complete/合成玉",
|
||||
"infra_complete/术师双芯片",
|
||||
"infra_complete/源石碎片",
|
||||
"infra_complete/特种双芯片",
|
||||
"infra_complete/狙击双芯片",
|
||||
"infra_complete/经验",
|
||||
"infra_complete/赤金",
|
||||
"infra_complete/辅助双芯片",
|
||||
"infra_complete/近卫双芯片",
|
||||
"infra_complete/重装双芯片",
|
||||
"infra_complete/龙门币",
|
||||
"infra_exp_complete",
|
||||
"infra_gold_complete",
|
||||
"infra_lmd_complete",
|
||||
"infra_no_operator",
|
||||
"infra_notification",
|
||||
"infra_ori_complete",
|
||||
"infra_oru_complete",
|
||||
"infra_overview",
|
||||
"infra_overview_in",
|
||||
"infra_overview_top_right",
|
||||
"infra_todo",
|
||||
"infra_trust_complete",
|
||||
"loading",
|
||||
"loading2",
|
||||
"loading3",
|
||||
"loading4",
|
||||
"loading7",
|
||||
"login_account",
|
||||
"login_awake",
|
||||
"login_bilibili",
|
||||
"login_bilibili_privacy",
|
||||
"login_captcha",
|
||||
"login_connecting",
|
||||
"login_logo",
|
||||
"mail",
|
||||
"main_theme",
|
||||
"materiel_ico",
|
||||
"mission_collect",
|
||||
"mission_daily",
|
||||
"mission_daily_on",
|
||||
"mission_trainee_on",
|
||||
"mission_weekly",
|
||||
"mission_weekly_on",
|
||||
"nav_bar",
|
||||
"nav_button",
|
||||
"navigation/act/0",
|
||||
"navigation/act/1",
|
||||
"navigation/act/2",
|
||||
"navigation/activity/banner",
|
||||
"navigation/activity/entry",
|
||||
"navigation/biography/OF_banner",
|
||||
"navigation/biography/OF_entry",
|
||||
"navigation/collection/AP-1",
|
||||
"navigation/collection/AP_entry",
|
||||
"navigation/collection/AP_not_available",
|
||||
"navigation/collection/CA-1",
|
||||
"navigation/collection/CA_entry",
|
||||
"navigation/collection/CA_not_available",
|
||||
"navigation/collection/CE-1",
|
||||
"navigation/collection/CE_entry",
|
||||
"navigation/collection/CE_not_available",
|
||||
"navigation/collection/LS-1",
|
||||
"navigation/collection/LS_entry",
|
||||
"navigation/collection/PR-A-1",
|
||||
"navigation/collection/PR-A_entry",
|
||||
"navigation/collection/PR-A_not_available",
|
||||
"navigation/collection/PR-B-1",
|
||||
"navigation/collection/PR-B_entry",
|
||||
"navigation/collection/PR-B_not_available",
|
||||
"navigation/collection/PR-C-1",
|
||||
"navigation/collection/PR-C_entry",
|
||||
"navigation/collection/PR-C_not_available",
|
||||
"navigation/collection/PR-D-1",
|
||||
"navigation/collection/PR-D_entry",
|
||||
"navigation/collection/PR-D_not_available",
|
||||
"navigation/collection/SK-1",
|
||||
"navigation/collection/SK_entry",
|
||||
"navigation/collection/SK_not_available",
|
||||
"navigation/entry",
|
||||
"navigation/episode",
|
||||
"navigation/main/0",
|
||||
"navigation/main/1",
|
||||
"navigation/main/10",
|
||||
"navigation/main/11",
|
||||
"navigation/main/12",
|
||||
"navigation/main/13",
|
||||
"navigation/main/14",
|
||||
"navigation/main/2",
|
||||
"navigation/main/3",
|
||||
"navigation/main/4",
|
||||
"navigation/main/5",
|
||||
"navigation/main/6",
|
||||
"navigation/main/7",
|
||||
"navigation/main/8",
|
||||
"navigation/main/9",
|
||||
"navigation/ope_difficulty",
|
||||
"navigation/ope_hard",
|
||||
"navigation/ope_hard_small",
|
||||
"navigation/ope_normal",
|
||||
"navigation/ope_normal_small",
|
||||
"navigation/record_restoration",
|
||||
"next_step",
|
||||
"notice",
|
||||
"one_hour",
|
||||
"ope_agency_fail",
|
||||
"ope_agency_going",
|
||||
"ope_agency_lock",
|
||||
"ope_elimi_agency",
|
||||
"ope_elimi_agency_confirm",
|
||||
"ope_elimi_agency_panel",
|
||||
"ope_elimi_finished",
|
||||
"ope_eliminate",
|
||||
"ope_failed",
|
||||
"ope_finish",
|
||||
"ope_plan",
|
||||
"ope_recover_originite_on",
|
||||
"ope_recover_potion_on",
|
||||
"ope_select_start",
|
||||
"ope_select_start_empty",
|
||||
"ope_start",
|
||||
"open_recruitment",
|
||||
"order_label",
|
||||
"order_ready",
|
||||
"order_switching_notice",
|
||||
"orders_time/0",
|
||||
"orders_time/1",
|
||||
"orders_time/2",
|
||||
"orders_time/3",
|
||||
"orders_time/4",
|
||||
"orders_time/5",
|
||||
"orders_time/6",
|
||||
"orders_time/7",
|
||||
"orders_time/8",
|
||||
"orders_time/9",
|
||||
"originite",
|
||||
"product/先锋双芯片",
|
||||
"product/医疗双芯片",
|
||||
"product/术师双芯片",
|
||||
"product/源石碎片",
|
||||
"product/特种双芯片",
|
||||
"product/狙击双芯片",
|
||||
"product/经验",
|
||||
"product/赤金",
|
||||
"product/辅助双芯片",
|
||||
"product/近卫双芯片",
|
||||
"product/重装双芯片",
|
||||
"product_be_choosen",
|
||||
"pull_once",
|
||||
"ra/action_points",
|
||||
"ra/adventure",
|
||||
"ra/adventure_ok",
|
||||
"ra/ap-1",
|
||||
"ra/auto+1",
|
||||
"ra/battle_complete",
|
||||
"ra/battle_exit",
|
||||
"ra/battle_exit_dialog",
|
||||
"ra/click_anywhere",
|
||||
"ra/click_to_continue",
|
||||
"ra/confirm_green",
|
||||
"ra/confirm_red",
|
||||
"ra/continue_button",
|
||||
"ra/cook_button",
|
||||
"ra/day_1",
|
||||
"ra/day_2",
|
||||
"ra/day_3",
|
||||
"ra/day_4",
|
||||
"ra/day_complete",
|
||||
"ra/day_next",
|
||||
"ra/days",
|
||||
"ra/delete_save",
|
||||
"ra/delete_save_confirm_dialog",
|
||||
"ra/delete_save_confirm_dialog_ok_button",
|
||||
"ra/dialog_cancel",
|
||||
"ra/drink_0",
|
||||
"ra/drink_2",
|
||||
"ra/drink_4",
|
||||
"ra/enter_battle_confirm_dialog",
|
||||
"ra/get_item",
|
||||
"ra/guide_dialog",
|
||||
"ra/guide_entrance",
|
||||
"ra/main_title",
|
||||
"ra/map/base",
|
||||
"ra/map/冲突区_丢失的订单",
|
||||
"ra/map/后舍_众人会聚之地",
|
||||
"ra/map/奇遇_崎岖窄路",
|
||||
"ra/map/奇遇_砾沙平原",
|
||||
"ra/map/奇遇_风啸峡谷",
|
||||
"ra/map/捕猎区_聚羽之地",
|
||||
"ra/map/要塞_征税的选择",
|
||||
"ra/map/资源区_射程以内",
|
||||
"ra/map/资源区_林中寻宝",
|
||||
"ra/map_back",
|
||||
"ra/max",
|
||||
"ra/no_enough_drink",
|
||||
"ra/no_enough_resources",
|
||||
"ra/notice",
|
||||
"ra/out_of_drink",
|
||||
"ra/period_complete",
|
||||
"ra/period_complete_start_new_day",
|
||||
"ra/popup",
|
||||
"ra/prepared_0",
|
||||
"ra/prepared_1",
|
||||
"ra/prepared_2",
|
||||
"ra/return_from_kitchen",
|
||||
"ra/save",
|
||||
"ra/shop",
|
||||
"ra/spring",
|
||||
"ra/squad_back",
|
||||
"ra/squad_edit",
|
||||
"ra/squad_edit_confirm_dialog",
|
||||
"ra/squad_edit_start_button",
|
||||
"ra/start_action",
|
||||
"ra/start_button",
|
||||
"ra/waste_time_button",
|
||||
"ra/waste_time_dialog",
|
||||
"read_and_agree",
|
||||
"read_mail",
|
||||
"recruit/agent_token",
|
||||
"recruit/agent_token_first",
|
||||
"recruit/available_level",
|
||||
"recruit/begin_recruit",
|
||||
"recruit/career_needs",
|
||||
"recruit/choose_template/normal",
|
||||
"recruit/choose_template/rare",
|
||||
"recruit/job_requirements",
|
||||
"recruit/lmb",
|
||||
"recruit/recruit_done",
|
||||
"recruit/recruit_lock",
|
||||
"recruit/refresh",
|
||||
"recruit/refresh_comfirm",
|
||||
"recruit/riic_res/CASTER",
|
||||
"recruit/riic_res/MEDIC",
|
||||
"recruit/riic_res/PIONEER",
|
||||
"recruit/riic_res/SNIPER",
|
||||
"recruit/riic_res/SPECIAL",
|
||||
"recruit/riic_res/SUPPORT",
|
||||
"recruit/riic_res/TANK",
|
||||
"recruit/riic_res/WARRIOR",
|
||||
"recruit/start_recruit",
|
||||
"recruit/stone",
|
||||
"recruit/ticket",
|
||||
"recruit/time",
|
||||
"recruiting_instructions",
|
||||
"reload_check",
|
||||
"riic/assistants",
|
||||
"riic/exp",
|
||||
"riic/iron",
|
||||
"riic/manufacture",
|
||||
"riic/orundum",
|
||||
"riic/report_title",
|
||||
"riic/trade",
|
||||
"room/1",
|
||||
"room/2",
|
||||
"room/3",
|
||||
"room/4",
|
||||
"room/central",
|
||||
"room/contact",
|
||||
"room/dormitory",
|
||||
"room/meeting",
|
||||
"room_detail",
|
||||
"sanity",
|
||||
"sanity_charge",
|
||||
"sanity_charge_dialog",
|
||||
"sf/available",
|
||||
"sf/card",
|
||||
"sf/click_anywhere",
|
||||
"sf/confirm",
|
||||
"sf/continue",
|
||||
"sf/continue_event",
|
||||
"sf/continue_result",
|
||||
"sf/end",
|
||||
"sf/entrance",
|
||||
"sf/exit",
|
||||
"sf/exit_button",
|
||||
"sf/failure",
|
||||
"sf/inheritance",
|
||||
"sf/lost_in_the_trick",
|
||||
"sf/percentage",
|
||||
"sf/properties",
|
||||
"sf/ranger",
|
||||
"sf/restart",
|
||||
"sf/select",
|
||||
"sf/select_team_ok",
|
||||
"sf/success",
|
||||
"sf/support_battle_platform",
|
||||
"sf/team_intelligence",
|
||||
"sf/team_management",
|
||||
"sf/team_medicine",
|
||||
"sf/team_pass",
|
||||
"shop/assist",
|
||||
"shop/cart",
|
||||
"shop/collect",
|
||||
"shop/commendation",
|
||||
"shop/credit",
|
||||
"shop/credit_not_enough",
|
||||
"shop/recommend",
|
||||
"shop/recommend_off",
|
||||
"shop/spent_credit",
|
||||
"shop/token",
|
||||
"shop/token_not_enough",
|
||||
"shop/trade_token_button",
|
||||
"shop/trade_token_dialog",
|
||||
"sign_in/banner",
|
||||
"sign_in/entry",
|
||||
"sign_in/moon_festival/banner",
|
||||
"sign_in/moon_festival/moon_cake",
|
||||
"skill_collect_confirm",
|
||||
"skill_confirm",
|
||||
"skip",
|
||||
"sss/A",
|
||||
"sss/deploy",
|
||||
"sss/device",
|
||||
"sss/ec",
|
||||
"sss/loading",
|
||||
"sss/main",
|
||||
"sss/redeploy",
|
||||
"sss/squad",
|
||||
"sss/start",
|
||||
"sss/terminated",
|
||||
"start",
|
||||
"story_skip",
|
||||
"story_skip_confirm_dialog",
|
||||
"switch_order/check",
|
||||
"switch_order/lmb",
|
||||
"switch_order/oru",
|
||||
"terminal_eliminate",
|
||||
"terminal_longterm",
|
||||
"terminal_longterm_reclamation_algorithm",
|
||||
"terminal_main",
|
||||
"terminal_regular",
|
||||
"train_main",
|
||||
"training_completed",
|
||||
"training_support",
|
||||
"upgrade",
|
||||
"upgrade_failure",
|
||||
"visit_limit",
|
||||
]
|
111
mower/utils/update.py
Normal file
111
mower/utils/update.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
import os
|
||||
import zipfile
|
||||
|
||||
import requests
|
||||
|
||||
from .. import __version__
|
||||
|
||||
|
||||
# 编写bat脚本,删除旧程序,运行新程序
|
||||
def __write_restart_cmd(new_name, old_name):
|
||||
b = open("upgrade.bat", "w")
|
||||
TempList = "@echo off\n"
|
||||
TempList += (
|
||||
"if not exist " + new_name + " exit \n"
|
||||
) # 判断是否有新版本的程序,没有就退出更新。
|
||||
TempList += "echo 正在更新至最新版本...\n"
|
||||
TempList += "timeout /t 5 /nobreak\n" # 等待5秒
|
||||
TempList += (
|
||||
"if exist " + old_name + ' del "' + old_name.replace("/", "\\\\") + '"\n'
|
||||
) # 删除旧程序
|
||||
TempList += (
|
||||
'copy "'
|
||||
+ new_name.replace("/", "\\\\")
|
||||
+ '" "'
|
||||
+ old_name.replace("/", "\\\\")
|
||||
+ '"\n'
|
||||
) # 复制新版本程序
|
||||
TempList += "echo 更新完成,正在启动...\n"
|
||||
TempList += "timeout /t 3 /nobreak\n"
|
||||
TempList += "start " + old_name + " \n" # "start 1.bat\n"
|
||||
TempList += "exit"
|
||||
b.write(TempList)
|
||||
b.close()
|
||||
# subprocess.Popen("upgrade.bat") #不显示cmd窗口
|
||||
os.system("start upgrade.bat") # 显示cmd窗口
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def compere_version():
|
||||
"""
|
||||
与github上最新版比较
|
||||
:return res: str | None, 若需要更新 返回版本号, 否则返回None
|
||||
"""
|
||||
newest_version = __get_newest_version()
|
||||
|
||||
v1 = [str(x) for x in str(__version__).split(".")]
|
||||
v2 = [str(x) for x in str(newest_version).split(".")]
|
||||
|
||||
# 如果2个版本号位数不一致,后面使用0补齐,使2个list长度一致,便于后面做对比
|
||||
if len(v1) > len(v2):
|
||||
v2 += [str(0) for x in range(len(v1) - len(v2))]
|
||||
elif len(v1) < len(v2):
|
||||
v1 += [str(0) for x in range(len(v2) - len(v1))]
|
||||
list_sort = sorted([v1, v2])
|
||||
if list_sort[0] == list_sort[1]:
|
||||
return None
|
||||
elif list_sort[0] == v1:
|
||||
return newest_version
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def update_version():
|
||||
if os.path.isfile("upgrade.bat"):
|
||||
os.remove("upgrade.bat")
|
||||
__write_restart_cmd("tmp/mower.exe", "./mower.exe")
|
||||
|
||||
|
||||
def __get_newest_version():
|
||||
response = requests.get(
|
||||
"https://api.github.com/repos/ArkMowers/arknights-mower/releases/latest"
|
||||
)
|
||||
return response.json()["tag_name"]
|
||||
|
||||
|
||||
def download_version(version):
|
||||
if not os.path.isdir("./tmp"):
|
||||
os.makedirs("./tmp")
|
||||
r = requests.get(
|
||||
f"https://github.com/ArkMowers/arknights-mower/releases/download/{version}/mower.zip",
|
||||
stream=True,
|
||||
)
|
||||
# r = requests.get(
|
||||
# f"https://github.com/ArkMowers/arknights-mower/releases/download/{version}/arknights-mower-3.0.4.zip",
|
||||
# stream=True)
|
||||
total = int(r.headers.get("content-length", 0))
|
||||
index = 0
|
||||
with open("./tmp/mower.zip", "wb") as f:
|
||||
for chunk in r.iter_content(chunk_size=10485760):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
index += len(chunk)
|
||||
print(f"更新进度:{'%.2f%%' % (index*100 / total)}({index}/{total})")
|
||||
zip_file = zipfile.ZipFile("./tmp/mower.zip")
|
||||
zip_list = zip_file.namelist()
|
||||
|
||||
for f in zip_list:
|
||||
zip_file.extract(f, "./tmp/")
|
||||
zip_file.close()
|
||||
os.remove("./tmp/mower.zip")
|
||||
|
||||
|
||||
def main():
|
||||
# 新程序启动时,删除旧程序制造的脚本
|
||||
if os.path.isfile("upgrade.bat"):
|
||||
os.remove("upgrade.bat")
|
||||
__write_restart_cmd("newVersion.exe", "Version.exe")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
compere_version()
|
21
mower/utils/vector.py
Normal file
21
mower/utils/vector.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
from mower.utils import typealias as tp
|
||||
|
||||
|
||||
def va(a: tp.Coordinate, b: tp.Coordinate) -> tp.Coordinate:
|
||||
"""向量加法,vector add"""
|
||||
return a[0] + b[0], a[1] + b[1]
|
||||
|
||||
|
||||
def vs(a: tp.Coordinate, b: tp.Coordinate) -> tp.Coordinate:
|
||||
"""向量减法,vector subtract"""
|
||||
return a[0] - b[0], a[1] - b[1]
|
||||
|
||||
|
||||
def sa(scope: tp.Scope, vector: tp.Coordinate) -> tp.Scope:
|
||||
"""区域偏移,scope add"""
|
||||
return va(scope[0], vector), va(scope[1], vector)
|
||||
|
||||
|
||||
def sm(a: float, v: tp.Coordinate) -> tp.Coordinate:
|
||||
"""数乘向量,scalar multiply"""
|
||||
return round(a * v[0]), round(a * v[1])
|
Loading…
Add table
Add a link
Reference in a new issue