改写战斗中替换group的逻辑

This commit is contained in:
Elaina 2024-10-13 01:24:58 +08:00
commit 7f89eb0db8
3890 changed files with 82290 additions and 0 deletions

318
mower/utils/SecuritySm.py Normal file
View 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
View file

View 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))

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View file

View 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 # 多媒体键 >> 录音

View 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)

View 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")

View 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)

View 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

View 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

View file

@ -0,0 +1,3 @@
from mower.utils.device.maatouch.core import Client as MaaTouch
__all__ = ["MaaTouch"]

View 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 = ""

View 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
)

View 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()

View 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.

View file

@ -0,0 +1,3 @@
from .core import Client as Scrcpy
__all__ = ["Scrcpy"]

View 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

View 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)

View 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
View 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
View 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
View 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()

View 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",
]

View 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)

View 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
View 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")

View 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")

View 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")

View 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))

View 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
View 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
View 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
View 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)

View 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))

View 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
View 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
View 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))

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)

View 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)

View 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
View 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: "确认对话框",
}

View 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
View 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
View 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+时间戳+ 请求头的四个重要参数dIdplatformtimestampvName.toJSON()
将此字符串做HMAC加密算法为SHA-256密钥token为请求cred接口会返回的一个token值
再将加密后的字符串做MD5即得到sign
:param token: 拿cred时候的token
:param path: 请求路径不包括网址
:param body_or_query: 如果是GET则是它的queryPOST则为它的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
View 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
View 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
View 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}"

View 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

View 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]

View 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
View 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
View 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])