mirror of
https://github.com/babalae/better-genshin-impact.git
synced 2026-05-23 09:55:48 +08:00
Merge branch 'main' into d-v3
This commit is contained in:
27
.cnb.yml
Normal file
27
.cnb.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
main:
|
||||
# 自定义按钮可触发的事件
|
||||
web_trigger_one:
|
||||
- docker:
|
||||
image: python:3.11
|
||||
imports:
|
||||
- https://cnb.cool/bettergi/secret/-/blob/main/env.yml
|
||||
stages:
|
||||
- name: 下载构建物并上传
|
||||
script: |
|
||||
cd .github/workflows
|
||||
pip install -r requirements.txt
|
||||
python github_download_and_cnb_upload.py --cnb-token $CNB_TOKEN --github-token $GITHUB_TOKEN
|
||||
echo done!
|
||||
api_trigger_one:
|
||||
- docker:
|
||||
image: python:3.11
|
||||
imports:
|
||||
- https://cnb.cool/bettergi/secret/-/blob/main/env.yml
|
||||
stages:
|
||||
- name: API触发上传
|
||||
script: |
|
||||
echo run id: $RUN_ID
|
||||
cd .github/workflows
|
||||
pip install -r requirements.txt
|
||||
python github_download_and_cnb_upload.py --cnb-token $CNB_TOKEN --github-token $GITHUB_TOKEN --run-id $RUN_ID
|
||||
echo api done!
|
||||
5
.cnb/web_trigger.yml
Normal file
5
.cnb/web_trigger.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
branch:
|
||||
- buttons:
|
||||
- name: 触发上传
|
||||
description: 上传测试版或者正式版
|
||||
event: web_trigger_one
|
||||
75
.github/workflows/cnb_release.py
vendored
75
.github/workflows/cnb_release.py
vendored
@@ -1,3 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
CNB Release Uploader
|
||||
|
||||
依赖:
|
||||
- requests
|
||||
- tqdm
|
||||
|
||||
安装依赖:
|
||||
pip install requests tqdm
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import requests
|
||||
@@ -6,6 +19,7 @@ import sys
|
||||
import argparse
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
|
||||
|
||||
class CNBReleaseUploader:
|
||||
@@ -103,13 +117,14 @@ class CNBReleaseUploader:
|
||||
print(f" 响应内容: {e.response.text}")
|
||||
return None
|
||||
|
||||
def upload_asset(self, upload_url: str, file_path: str) -> bool:
|
||||
def upload_asset(self, upload_url: str, file_path: str, show_progress: bool = True) -> bool:
|
||||
"""
|
||||
上传asset文件
|
||||
|
||||
Args:
|
||||
upload_url: 上传URL
|
||||
file_path: 本地文件路径
|
||||
show_progress: 是否显示上传进度条
|
||||
|
||||
Returns:
|
||||
是否上传成功
|
||||
@@ -123,9 +138,44 @@ class CNBReleaseUploader:
|
||||
'Authorization': f'Bearer {self.token}',
|
||||
}
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
file_name = os.path.basename(file_path)
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as file:
|
||||
response = requests.put(upload_url, headers=upload_headers, data=file)
|
||||
if show_progress and file_size > 0:
|
||||
# 创建进度条
|
||||
progress_bar = tqdm(
|
||||
total=file_size,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
desc=f"📤 上传 {file_name}",
|
||||
ncols=80,
|
||||
bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]'
|
||||
)
|
||||
|
||||
# 创建一个包装器来更新进度条
|
||||
class ProgressFileWrapper:
|
||||
def __init__(self, file_obj, progress_bar):
|
||||
self.file_obj = file_obj
|
||||
self.progress_bar = progress_bar
|
||||
|
||||
def read(self, size=-1):
|
||||
data = self.file_obj.read(size)
|
||||
if data:
|
||||
self.progress_bar.update(len(data))
|
||||
return data
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.file_obj, name)
|
||||
|
||||
wrapped_file = ProgressFileWrapper(file, progress_bar)
|
||||
response = requests.put(upload_url, headers=upload_headers, data=wrapped_file)
|
||||
progress_bar.close()
|
||||
else:
|
||||
response = requests.put(upload_url, headers=upload_headers, data=file)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
print(f"📤 上传到 {upload_url} 返回结果: {response.status_code}")
|
||||
@@ -177,7 +227,7 @@ class CNBReleaseUploader:
|
||||
return False
|
||||
|
||||
def upload_multiple_assets(self, project_path: str, release_id: str,
|
||||
asset_files: List[str], overwrite: bool = True) -> List[bool]:
|
||||
asset_files: List[str], overwrite: bool = True, show_progress: bool = True) -> List[bool]:
|
||||
"""
|
||||
上传多个assets
|
||||
|
||||
@@ -186,6 +236,7 @@ class CNBReleaseUploader:
|
||||
release_id: release ID
|
||||
asset_files: asset文件路径列表
|
||||
overwrite: 是否覆盖现有文件
|
||||
show_progress: 是否显示进度条
|
||||
|
||||
Returns:
|
||||
每个文件的上传结果列表
|
||||
@@ -193,6 +244,18 @@ class CNBReleaseUploader:
|
||||
results = []
|
||||
|
||||
print(f"\n📦 开始上传 {len(asset_files)} 个文件到release {release_id}...")
|
||||
|
||||
# 计算总文件大小用于整体进度显示
|
||||
total_size = 0
|
||||
valid_files = []
|
||||
for file_path in asset_files:
|
||||
if os.path.exists(file_path):
|
||||
total_size += os.path.getsize(file_path)
|
||||
valid_files.append(file_path)
|
||||
|
||||
if show_progress and valid_files:
|
||||
print(f"📊 总计 {len(valid_files)} 个有效文件,总大小: {total_size / 1024 / 1024:.2f} MB")
|
||||
print("" + "=" * 60)
|
||||
|
||||
for i, file_path in enumerate(asset_files, 1):
|
||||
if not os.path.exists(file_path):
|
||||
@@ -216,7 +279,7 @@ class CNBReleaseUploader:
|
||||
continue
|
||||
|
||||
# 2. 上传文件
|
||||
upload_success = self.upload_asset(upload_info['upload_url'], file_path)
|
||||
upload_success = self.upload_asset(upload_info['upload_url'], file_path, show_progress)
|
||||
time.sleep(1)
|
||||
|
||||
# 3. 验证上传(如果有验证URL)
|
||||
@@ -299,6 +362,7 @@ def main():
|
||||
parser = argparse.ArgumentParser(description='CNB Release Uploader - JSON配置版本')
|
||||
parser.add_argument('config', help='JSON配置字符串或JSON配置文件路径')
|
||||
parser.add_argument('--dry-run', action='store_true', help='只验证配置,不执行上传')
|
||||
parser.add_argument('--no-progress', action='store_true', help='禁用上传进度条显示')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -346,8 +410,9 @@ def main():
|
||||
asset_files = config.get('asset_files', [])
|
||||
if asset_files:
|
||||
overwrite = config.get('overwrite', True)
|
||||
show_progress = not args.no_progress # 默认显示进度条,除非指定 --no-progress
|
||||
results = uploader.upload_multiple_assets(
|
||||
config['project_path'], release_id, asset_files, overwrite
|
||||
config['project_path'], release_id, asset_files, overwrite, show_progress
|
||||
)
|
||||
|
||||
# 3. 显示结果
|
||||
|
||||
94
.github/workflows/cnb_trigger.py
vendored
Normal file
94
.github/workflows/cnb_trigger.py
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Better Genshin Impact Build Trigger Script
|
||||
用于触发CNB构建的Python脚本
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
def trigger_build(token, branch="main", event="api_trigger_one", runid=None):
|
||||
"""
|
||||
触发构建请求
|
||||
|
||||
Args:
|
||||
token (str): 授权token
|
||||
branch (str): 分支名称,默认为main
|
||||
event (str): 事件类型,默认为api_trigger_one
|
||||
runid (str): 运行ID,可选参数
|
||||
|
||||
Returns:
|
||||
dict: API响应结果
|
||||
"""
|
||||
url = "https://api.cnb.cool/bettergi/better-genshin-impact/-/build/start"
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Host": "api.cnb.cool",
|
||||
"Connection": "keep-alive"
|
||||
}
|
||||
|
||||
data = {
|
||||
"branch": branch,
|
||||
"event": event
|
||||
}
|
||||
|
||||
# 如果提供了runid,则添加到env中
|
||||
if runid:
|
||||
data["env"] = {
|
||||
"RUN_ID": runid
|
||||
}
|
||||
|
||||
try:
|
||||
print(f"正在发起构建请求...")
|
||||
print(f"URL: {url}")
|
||||
|
||||
print(f"请求体: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
|
||||
print(f"响应状态码: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print("构建触发成功!")
|
||||
print(f"响应内容: {json.dumps(result, indent=2, ensure_ascii=False)}")
|
||||
return result
|
||||
else:
|
||||
print(f"请求失败: {response.status_code}")
|
||||
print(f"错误信息: {response.text}")
|
||||
return None
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"请求异常: {e}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSON解析错误: {e}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return None
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="触发Better Genshin Impact构建")
|
||||
parser.add_argument("token", help="授权token")
|
||||
parser.add_argument("--branch", default="main", help="分支名称 (默认: main)")
|
||||
parser.add_argument("--event", default="api_trigger_one", help="事件类型 (默认: api_trigger_one)")
|
||||
parser.add_argument("--runid", help="运行ID (可选)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token:
|
||||
print("错误: 必须提供token参数")
|
||||
sys.exit(1)
|
||||
|
||||
result = trigger_build(args.token, args.branch, args.event, args.runid)
|
||||
|
||||
if result is None:
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
507
.github/workflows/github_download_and_cnb_upload.py
vendored
Normal file
507
.github/workflows/github_download_and_cnb_upload.py
vendored
Normal file
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GitHub Actions 构建物下载和上传脚本
|
||||
|
||||
该脚本用于:
|
||||
1. 从 GitHub Actions 下载最新的构建物 (BetterGI_7z 和 BetterGI_Install)
|
||||
2. 解压构建物到本地
|
||||
3. 调用 cnb_release.py 上传文件到 CNB
|
||||
|
||||
使用方法:
|
||||
python github_download_and_cnb_upload.py --cnb-token YOUR_CNB_TOKEN [--github-token YOUR_GITHUB_TOKEN] [--run-id RUN_ID]
|
||||
|
||||
参数说明:
|
||||
--cnb-token: CNB API Token (必需)
|
||||
--github-token: GitHub Personal Access Token (可选,用于提高API限制)
|
||||
--run-id: 指定 GitHub Actions 运行 ID (可选,默认获取最新运行)
|
||||
|
||||
依赖:
|
||||
- requests: HTTP 请求库
|
||||
- tqdm: 进度条显示库
|
||||
|
||||
安装依赖:pip install -r requirements.txt
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from tqdm import tqdm
|
||||
|
||||
# 导入 CNBReleaseUploader
|
||||
from cnb_release import CNBReleaseUploader
|
||||
|
||||
|
||||
class GitHubActionsDownloader:
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""
|
||||
初始化 GitHub Actions 下载器
|
||||
|
||||
Args:
|
||||
token: GitHub Personal Access Token (可选,用于提高API限制)
|
||||
"""
|
||||
self.token = token
|
||||
self.headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'BetterGI-Downloader/1.0.0'
|
||||
}
|
||||
if token:
|
||||
self.headers['Authorization'] = f'token {token}'
|
||||
|
||||
def get_latest_workflow_run(self, owner: str, repo: str, workflow_file: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取最新的工作流运行
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
workflow_file: 工作流文件名
|
||||
|
||||
Returns:
|
||||
最新的工作流运行信息或None
|
||||
"""
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/actions/workflows/{workflow_file}/runs'
|
||||
params = {
|
||||
'status': 'completed',
|
||||
'conclusion': 'success',
|
||||
'per_page': 1
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers, params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
runs = data.get('workflow_runs', [])
|
||||
|
||||
if not runs:
|
||||
print("❌ 没有找到成功完成的工作流运行")
|
||||
return None
|
||||
|
||||
latest_run = runs[0]
|
||||
print(f"✅ 找到最新的工作流运行:")
|
||||
print(f" Run ID: {latest_run['id']}")
|
||||
print(f" 创建时间: {latest_run['created_at']}")
|
||||
print(f" 状态: {latest_run['status']} / {latest_run['conclusion']}")
|
||||
print(f" 分支: {latest_run['head_branch']}")
|
||||
|
||||
return latest_run
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ 获取工作流运行失败: {e}")
|
||||
return None
|
||||
|
||||
def get_artifacts(self, owner: str, repo: str, run_id: int) -> List[Dict]:
|
||||
"""
|
||||
获取指定运行的构建物列表
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
run_id: 运行ID
|
||||
|
||||
Returns:
|
||||
构建物列表
|
||||
"""
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts'
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
artifacts = data.get('artifacts', [])
|
||||
|
||||
print(f"📦 找到 {len(artifacts)} 个构建物:")
|
||||
for artifact in artifacts:
|
||||
print(f" - {artifact['name']} ({artifact['size_in_bytes']:,} bytes)")
|
||||
|
||||
return artifacts
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ 获取构建物列表失败: {e}")
|
||||
return []
|
||||
|
||||
def download_artifact(self, owner: str, repo: str, artifact_id: int,
|
||||
artifact_name: str, download_dir: str) -> Optional[str]:
|
||||
"""
|
||||
下载指定的构建物
|
||||
|
||||
Args:
|
||||
owner: 仓库所有者
|
||||
repo: 仓库名称
|
||||
artifact_id: 构建物ID
|
||||
artifact_name: 构建物名称
|
||||
download_dir: 下载目录
|
||||
|
||||
Returns:
|
||||
下载的文件路径或None
|
||||
"""
|
||||
url = f'https://api.github.com/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip'
|
||||
|
||||
try:
|
||||
print(f"📥 开始下载构建物: {artifact_name}")
|
||||
response = requests.get(url, headers=self.headers, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# 获取文件总大小
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
|
||||
# 保存到临时文件
|
||||
zip_path = os.path.join(download_dir, f"{artifact_name}.zip")
|
||||
|
||||
# 使用 tqdm 创建进度条
|
||||
chunk_size = 8192
|
||||
with open(zip_path, 'wb') as f:
|
||||
with tqdm(
|
||||
total=total_size,
|
||||
unit='B',
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
desc=f"下载 {artifact_name}",
|
||||
ncols=80,
|
||||
bar_format='{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]'
|
||||
) as pbar:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
pbar.update(len(chunk))
|
||||
|
||||
print(f"✅ 下载完成: {zip_path}")
|
||||
return zip_path
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ 下载构建物失败 ({artifact_name}): {e}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def extract_artifact(self, zip_path: str, extract_dir: str) -> List[str]:
|
||||
"""
|
||||
解压构建物
|
||||
|
||||
Args:
|
||||
zip_path: ZIP文件路径
|
||||
extract_dir: 解压目录
|
||||
|
||||
Returns:
|
||||
解压出的文件列表
|
||||
"""
|
||||
extracted_files = []
|
||||
|
||||
try:
|
||||
print(f"📂 解压构建物: {os.path.basename(zip_path)}")
|
||||
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(extract_dir)
|
||||
extracted_files = [os.path.join(extract_dir, name) for name in zip_ref.namelist()]
|
||||
|
||||
print(f"✅ 解压完成,共 {len(extracted_files)} 个文件")
|
||||
for file_path in extracted_files:
|
||||
if os.path.isfile(file_path):
|
||||
size = os.path.getsize(file_path)
|
||||
print(f" - {os.path.basename(file_path)} ({size:,} bytes)")
|
||||
|
||||
return extracted_files
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 解压失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def extract_version_from_filename(filename: str) -> Optional[str]:
|
||||
"""
|
||||
从文件名中提取版本号
|
||||
|
||||
Args:
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
版本号或None
|
||||
"""
|
||||
# 去除扩展名
|
||||
import os
|
||||
filename_without_ext = os.path.splitext(filename)[0]
|
||||
|
||||
# 匹配版本号模式,如 v1.2.3, 1.2.3-alpha.1 等
|
||||
patterns = [
|
||||
r'v?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)',
|
||||
r'_v?([0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, filename_without_ext)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_cnb_config(files: List[str], version: str, token: str) -> Dict:
|
||||
"""
|
||||
创建CNB上传配置
|
||||
|
||||
Args:
|
||||
files: 要上传的文件列表
|
||||
version: 版本号
|
||||
token: CNB token
|
||||
|
||||
Returns:
|
||||
CNB配置字典
|
||||
"""
|
||||
# 判断是否为预发布版本
|
||||
is_prerelease = '-' in version
|
||||
make_latest = "false" if is_prerelease else "true"
|
||||
|
||||
config = {
|
||||
"token": token,
|
||||
"project_path": "bettergi/better-genshin-impact",
|
||||
"base_url": "https://api.cnb.cool",
|
||||
"overwrite": True,
|
||||
"release_data": {
|
||||
"tag_name": f"v{version}",
|
||||
"name": f"BetterGI v{version}",
|
||||
"body": f"BetterGI v{version} 自动发布",
|
||||
"draft": False,
|
||||
"prerelease": is_prerelease,
|
||||
"target_commitish": "main",
|
||||
"make_latest": make_latest
|
||||
},
|
||||
"asset_files": files
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
# 解析命令行参数
|
||||
parser = argparse.ArgumentParser(description='BetterGI 构建物下载和上传工具')
|
||||
parser.add_argument('--run-id', type=str, help='指定 GitHub Actions 运行 ID,如果提供则不会获取最新运行')
|
||||
parser.add_argument('--github-token', type=str, help='GitHub Personal Access Token')
|
||||
parser.add_argument('--cnb-token', type=str, required=True, help='CNB API Token (必需)')
|
||||
args = parser.parse_args()
|
||||
|
||||
print("🚀 BetterGI 构建物下载和上传工具")
|
||||
print("=" * 50)
|
||||
|
||||
# 获取 token,优先使用命令行参数,其次使用环境变量
|
||||
github_token = args.github_token or os.getenv('GITHUB_TOKEN')
|
||||
cnb_token = args.cnb_token or os.getenv('CNB_TOKEN')
|
||||
|
||||
if not cnb_token:
|
||||
print("❌ 错误: 请设置 CNB_TOKEN 环境变量")
|
||||
return 1
|
||||
|
||||
if not github_token:
|
||||
print("⚠️ 警告: 未设置 GITHUB_TOKEN,可能会遇到API限制")
|
||||
|
||||
# 确定运行 ID
|
||||
if args.run_id:
|
||||
print(f"\n🎯 使用指定的运行 ID: {args.run_id}")
|
||||
run_id = args.run_id
|
||||
else:
|
||||
# 创建下载器来获取最新运行 ID
|
||||
downloader = GitHubActionsDownloader(github_token)
|
||||
print("\n🔍 查找最新的工作流运行...")
|
||||
latest_run = downloader.get_latest_workflow_run('babalae', 'better-genshin-impact', 'publish.yml')
|
||||
if not latest_run:
|
||||
return 1
|
||||
run_id = str(latest_run['id'])
|
||||
|
||||
# 使用当前目录下的固定目录,以action运行ID命名
|
||||
work_dir = os.path.join(os.getcwd(), 'github_actions_cache', run_id)
|
||||
download_dir = os.path.join(work_dir, 'downloads')
|
||||
extract_dir = os.path.join(work_dir, 'extracted')
|
||||
|
||||
print(f"\n📁 使用工作目录: {work_dir}")
|
||||
|
||||
# 检查是否已存在解压后的文件
|
||||
all_files = []
|
||||
version = None
|
||||
|
||||
# 检查解压目录是否已存在且包含文件
|
||||
if os.path.exists(extract_dir):
|
||||
print("🔍 检查已存在的构建物...")
|
||||
existing_files = []
|
||||
# 预期的构建物名称
|
||||
expected_artifacts = ['BetterGI_7z', 'BetterGI_Install']
|
||||
|
||||
for artifact_name in expected_artifacts:
|
||||
artifact_extract_dir = os.path.join(extract_dir, artifact_name)
|
||||
if os.path.exists(artifact_extract_dir):
|
||||
for root, dirs, files in os.walk(artifact_extract_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
existing_files.append(file_path)
|
||||
|
||||
# 尝试从文件名提取版本号
|
||||
if not version:
|
||||
filename = os.path.basename(file_path)
|
||||
extracted_version = extract_version_from_filename(filename)
|
||||
if extracted_version:
|
||||
version = extracted_version
|
||||
|
||||
if existing_files and version:
|
||||
print(f"✅ 发现已存在的构建物 ({len(existing_files)} 个文件),跳过下载")
|
||||
print(f"📋 检测到版本号: {version}")
|
||||
all_files = existing_files
|
||||
else:
|
||||
print("⚠️ 已存在目录但未找到有效文件,将重新下载")
|
||||
|
||||
# 如果没有找到已存在的文件,则进行下载和解压
|
||||
if not all_files:
|
||||
print("📥 需要下载构建物,正在获取构建物信息...")
|
||||
|
||||
# 如果还没有创建下载器,现在创建
|
||||
if 'downloader' not in locals():
|
||||
downloader = GitHubActionsDownloader(github_token)
|
||||
|
||||
# 获取构建物列表
|
||||
print("\n📦 获取构建物列表...")
|
||||
artifacts = downloader.get_artifacts('babalae', 'better-genshin-impact', int(run_id))
|
||||
if not artifacts:
|
||||
return 1
|
||||
|
||||
# 筛选需要的构建物
|
||||
target_artifacts = []
|
||||
for artifact in artifacts:
|
||||
if artifact['name'] in ['BetterGI_7z', 'BetterGI_Install']:
|
||||
target_artifacts.append(artifact)
|
||||
|
||||
if len(target_artifacts) != 2:
|
||||
print(f"❌ 错误: 期望找到2个构建物,实际找到 {len(target_artifacts)} 个")
|
||||
return 1
|
||||
|
||||
print("📥 开始下载和解压构建物...")
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
|
||||
for artifact in target_artifacts:
|
||||
# 下载
|
||||
zip_path = downloader.download_artifact(
|
||||
'babalae', 'better-genshin-impact',
|
||||
artifact['id'], artifact['name'], download_dir
|
||||
)
|
||||
|
||||
if not zip_path:
|
||||
continue
|
||||
|
||||
# 解压
|
||||
artifact_extract_dir = os.path.join(extract_dir, artifact['name'])
|
||||
os.makedirs(artifact_extract_dir, exist_ok=True)
|
||||
|
||||
extracted_files = downloader.extract_artifact(zip_path, artifact_extract_dir)
|
||||
|
||||
# 收集文件并提取版本号
|
||||
for file_path in extracted_files:
|
||||
if os.path.isfile(file_path):
|
||||
all_files.append(file_path)
|
||||
|
||||
# 尝试从文件名提取版本号
|
||||
if not version:
|
||||
filename = os.path.basename(file_path)
|
||||
extracted_version = extract_version_from_filename(filename)
|
||||
if extracted_version:
|
||||
version = extracted_version
|
||||
print(f"📋 检测到版本号: {version}")
|
||||
|
||||
if not all_files:
|
||||
print("❌ 错误: 没有找到可上传的文件")
|
||||
return 1
|
||||
|
||||
if not version:
|
||||
print("❌ 错误: 无法从文件名中提取版本号")
|
||||
return 1
|
||||
|
||||
print(f"\n📋 准备上传 {len(all_files)} 个文件:")
|
||||
for file_path in all_files:
|
||||
size = os.path.getsize(file_path)
|
||||
print(f" - {os.path.basename(file_path)} ({size:,} bytes)")
|
||||
|
||||
# 创建CNB配置
|
||||
print("\n⚙️ 创建CNB配置...")
|
||||
cnb_config = create_cnb_config(all_files, version, cnb_token)
|
||||
|
||||
# 保存配置文件
|
||||
config_path = os.path.join(work_dir, 'cnb_config.json')
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(cnb_config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"✅ 配置文件已保存: {config_path}")
|
||||
|
||||
# 直接调用 CNBReleaseUploader
|
||||
print("\n🚀 开始上传到CNB...")
|
||||
|
||||
try:
|
||||
# 创建 CNBReleaseUploader 实例
|
||||
uploader = CNBReleaseUploader(
|
||||
token=cnb_config['token'],
|
||||
base_url=cnb_config.get('base_url', 'https://api.cnb.cool')
|
||||
)
|
||||
|
||||
# 创建 release
|
||||
print(f"📝 创建 release: {cnb_config['release_data']['name']}")
|
||||
release_result = uploader.create_release(
|
||||
project_path=cnb_config['project_path'],
|
||||
release_data=cnb_config['release_data']
|
||||
)
|
||||
|
||||
if not release_result:
|
||||
print("❌ 创建 release 失败")
|
||||
return 1
|
||||
|
||||
print(f"✅ Release 创建成功: {release_result['name']}")
|
||||
|
||||
# 上传文件
|
||||
print(f"📤 开始上传 {len(cnb_config['asset_files'])} 个文件...")
|
||||
upload_results = uploader.upload_multiple_assets(
|
||||
project_path=cnb_config['project_path'],
|
||||
release_id=release_result['id'],
|
||||
asset_files=cnb_config['asset_files'],
|
||||
overwrite=cnb_config.get('overwrite', True)
|
||||
)
|
||||
|
||||
# 检查上传结果
|
||||
success_count = sum(1 for result in upload_results if result)
|
||||
total_count = len(upload_results)
|
||||
|
||||
print(f"\n📊 上传结果汇总:")
|
||||
print(f" ✅ 成功: {success_count}/{total_count}")
|
||||
|
||||
if success_count < total_count:
|
||||
print(f" ❌ 失败: {total_count - success_count}/{total_count}")
|
||||
for i, result in enumerate(upload_results):
|
||||
if not result:
|
||||
file_name = os.path.basename(cnb_config['asset_files'][i])
|
||||
print(f" - {file_name}: 上传失败")
|
||||
|
||||
if success_count == total_count:
|
||||
print("\n🎉 所有文件上传完成!")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ 部分文件上传失败")
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ CNB上传失败: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
exit_code = main()
|
||||
sys.exit(exit_code)
|
||||
except KeyboardInterrupt:
|
||||
print("\n⚠️ 用户中断操作")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n💥 程序异常: {e}")
|
||||
sys.exit(1)
|
||||
61
.github/workflows/publish.yml
vendored
61
.github/workflows/publish.yml
vendored
@@ -21,6 +21,11 @@ on:
|
||||
description: '创建 GitHub Release 草稿'
|
||||
required: true
|
||||
default: false
|
||||
upload-to-steambird:
|
||||
type: boolean
|
||||
description: '上传到 Steambird'
|
||||
required: true
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
# Add validation job to check version format
|
||||
@@ -446,7 +451,7 @@ jobs:
|
||||
upload_token: ${{ secrets.MirrorChyanUploadToken }}
|
||||
|
||||
cnb_uploading:
|
||||
if: github.repository_owner == 'babalae'
|
||||
if: github.repository_owner == 'babalae' && false
|
||||
needs: [validate, build_dist, build_installer]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -460,7 +465,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
pip install -r .github/workflows/requirements.txt
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -537,3 +542,55 @@ jobs:
|
||||
|
||||
# 执行上传
|
||||
python cnb_release.py cnb_config.json
|
||||
|
||||
cnb_trigger:
|
||||
if: github.repository_owner == 'babalae'
|
||||
needs: [validate, build_dist, build_installer]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r .github/workflows/requirements.txt
|
||||
|
||||
- name: Trigger CNB Build
|
||||
env:
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
run: |
|
||||
python .github/workflows/cnb_trigger.py "$CNB_TOKEN" --runid ${{ github.run_id }}
|
||||
|
||||
upload_to_steambird:
|
||||
if: ${{ inputs.upload-to-steambird && github.repository_owner == 'babalae' }}
|
||||
needs: [validate, build_dist, build_installer]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare files for upload
|
||||
run: |
|
||||
mkdir -p upload_files
|
||||
# 只复制 7z 文件和 Install.exe 文件
|
||||
find artifacts -name "*.7z" -exec cp {} upload_files/ \;
|
||||
find artifacts -name "BetterGI.Install.*.exe" -exec cp {} upload_files/ \;
|
||||
echo "📦 准备上传的文件:"
|
||||
ls -la upload_files/
|
||||
|
||||
- name: Upload to Steambird
|
||||
env:
|
||||
TUS_USER: "bgi"
|
||||
TUS_PASS: ${{ secrets.TUS_PASS }}
|
||||
run: |
|
||||
wget https://uploads.steambird.pub/upload.sh
|
||||
chmod +x upload.sh
|
||||
./upload.sh https://uploads.steambird.pub/dav/bgi/ upload_files/BetterGI.Install.*.exe
|
||||
|
||||
|
||||
3
.github/workflows/requirements.txt
vendored
Normal file
3
.github/workflows/requirements.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# CNB Release Uploader 依赖
|
||||
requests>=2.25.0
|
||||
tqdm>=4.60.0
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -24,6 +24,7 @@ bld/
|
||||
Tmp/
|
||||
/packages/
|
||||
node_modules/
|
||||
github_actions_cache/
|
||||
|
||||
*.zip
|
||||
|
||||
@@ -31,4 +32,6 @@ node_modules/
|
||||
.idea
|
||||
.trae
|
||||
.claude
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
__pycache__/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>BetterGI</AssemblyName>
|
||||
<Version>0.48.2-alpha.4</Version>
|
||||
<Version>0.49.0</Version>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
|
||||
@@ -63,6 +63,7 @@
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime.Managed" Version="1.21.0" />
|
||||
<PackageReference Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2592.51" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||
<PackageReference Include="OpenCvSharp4.WpfExtensions" Version="4.11.0.20250507" />
|
||||
<PackageReference Include="OpenCvSharp4.Extensions" Version="4.11.0.20250507" />
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BetterGenshinImpact.GameTask.AutoArtifactSalvage
|
||||
{
|
||||
/// <summary>
|
||||
/// 圣遗物词条
|
||||
/// </summary>
|
||||
public class ArtifactAffix
|
||||
{
|
||||
public ArtifactAffix(ArtifactAffixType type, float value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public ArtifactAffixType Type { get; private set; }
|
||||
public float Value { get; private set; }
|
||||
public static FrozenDictionary<ArtifactAffixType, string> DefaultStrDic { get; } = new Dictionary<ArtifactAffixType, string>() {
|
||||
{ ArtifactAffixType.ATK, "攻击力" },
|
||||
{ ArtifactAffixType.ATKPercent, "攻击力" },
|
||||
{ ArtifactAffixType.DEF, "防御力" },
|
||||
{ ArtifactAffixType.DEFPercent, "防御力" },
|
||||
{ ArtifactAffixType.HP, "生命值" },
|
||||
{ ArtifactAffixType.HPPercent, "生命值" },
|
||||
{ ArtifactAffixType.CRITRate, "暴击率" },
|
||||
{ ArtifactAffixType.CRITDMG, "暴击伤害" },
|
||||
{ ArtifactAffixType.ElementalMastery, "元素精通" },
|
||||
{ ArtifactAffixType.EnergyRecharge, "元素充能效率" },
|
||||
{ ArtifactAffixType.HealingBonus, "治疗加成" },
|
||||
{ ArtifactAffixType.PhysicalDMGBonus, "物理伤害加成" },
|
||||
{ ArtifactAffixType.PyroDMGBonus, "火元素伤害加成" },
|
||||
{ ArtifactAffixType.HydroDMGBonus, "水元素伤害加成" },
|
||||
{ ArtifactAffixType.DendroDMGBonus, "草元素伤害加成" },
|
||||
{ ArtifactAffixType.ElectroDMGBonus, "雷元素伤害加成" },
|
||||
{ ArtifactAffixType.AnemoDMGBonus, "风元素伤害加成" },
|
||||
{ ArtifactAffixType.CryoDMGBonus, "冰元素伤害加成" },
|
||||
{ ArtifactAffixType.GeoDMGBonus, "岩元素伤害加成" }
|
||||
}.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
public enum ArtifactAffixType
|
||||
{
|
||||
ATK,
|
||||
ATKPercent,
|
||||
DEF,
|
||||
DEFPercent,
|
||||
HP,
|
||||
HPPercent,
|
||||
CRITRate,
|
||||
CRITDMG,
|
||||
ElementalMastery,
|
||||
EnergyRecharge,
|
||||
HealingBonus,
|
||||
PhysicalDMGBonus,
|
||||
/// <summary>
|
||||
/// 火
|
||||
/// </summary>
|
||||
PyroDMGBonus,
|
||||
/// <summary>
|
||||
/// 水
|
||||
/// </summary>
|
||||
HydroDMGBonus,
|
||||
/// <summary>
|
||||
/// 草
|
||||
/// </summary>
|
||||
DendroDMGBonus,
|
||||
/// <summary>
|
||||
/// 雷
|
||||
/// </summary>
|
||||
ElectroDMGBonus,
|
||||
/// <summary>
|
||||
/// 风
|
||||
/// </summary>
|
||||
AnemoDMGBonus,
|
||||
/// <summary>
|
||||
/// 冰
|
||||
/// </summary>
|
||||
CryoDMGBonus,
|
||||
/// <summary>
|
||||
/// 岩
|
||||
/// </summary>
|
||||
GeoDMGBonus,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace BetterGenshinImpact.GameTask.AutoArtifactSalvage
|
||||
{
|
||||
/// <summary>
|
||||
/// 圣遗物数值面板信息
|
||||
/// </summary>
|
||||
public class ArtifactStat
|
||||
{
|
||||
public ArtifactStat(string name, ArtifactAffix mainAffix, ArtifactAffix[] minorAffix, int level)
|
||||
{
|
||||
Name = name;
|
||||
MainAffix = mainAffix;
|
||||
MinorAffixes = minorAffix;
|
||||
Level = level;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 名称
|
||||
/// </summary>
|
||||
public string Name { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 主词条
|
||||
/// </summary>
|
||||
public ArtifactAffix MainAffix { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 副词条数组
|
||||
/// </summary>
|
||||
public ArtifactAffix[] MinorAffixes { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 等级
|
||||
/// </summary>
|
||||
public int Level { get; private set; }
|
||||
|
||||
// PS:圣遗物的种类和品质在点击查看之前就可以通过识别图标获悉,所以不必在此模型类中获取
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using System;
|
||||
|
||||
namespace BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
@@ -6,7 +6,17 @@ namespace BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
[Serializable]
|
||||
public partial class AutoArtifactSalvageConfig : ObservableObject
|
||||
{
|
||||
// JavaScript
|
||||
[ObservableProperty]
|
||||
private string _javaScript =
|
||||
@"(async function (artifact) {
|
||||
var hasATK = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'ATK');
|
||||
var hasDEF = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'DEF');
|
||||
Output = hasATK && hasDEF;
|
||||
})(ArtifactStat);";
|
||||
|
||||
// 正则表达式
|
||||
[Obsolete]
|
||||
[ObservableProperty]
|
||||
private string _regularExpression = @"(?=[\S\s]*攻击力\+[\d]*\n)(?=[\S\s]*防御力\+[\d]*\n)";
|
||||
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
using BetterGenshinImpact.Core.Recognition;
|
||||
using BetterGenshinImpact.Core.Recognition.OCR;
|
||||
using BetterGenshinImpact.Core.Recognition.OpenCv;
|
||||
using BetterGenshinImpact.Core.Simulator;
|
||||
using BetterGenshinImpact.Core.Simulator.Extensions;
|
||||
using BetterGenshinImpact.GameTask.Common;
|
||||
using BetterGenshinImpact.GameTask.Common.BgiVision;
|
||||
using BetterGenshinImpact.GameTask.Common.Element.Assets;
|
||||
using BetterGenshinImpact.GameTask.Common.Job;
|
||||
using BetterGenshinImpact.GameTask.Model.Area;
|
||||
using BetterGenshinImpact.GameTask.Model.GameUI;
|
||||
using BetterGenshinImpact.Helpers;
|
||||
using BetterGenshinImpact.Helpers.Extensions;
|
||||
using Fischless.WindowsInput;
|
||||
using Microsoft.ClearScript;
|
||||
using Microsoft.ClearScript.V8;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenCvSharp;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BetterGenshinImpact.Core.Recognition;
|
||||
using BetterGenshinImpact.Core.Simulator;
|
||||
using BetterGenshinImpact.GameTask.Common.Element.Assets;
|
||||
using BetterGenshinImpact.Helpers.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Vanara.PInvoke;
|
||||
using static BetterGenshinImpact.GameTask.Common.TaskControl;
|
||||
using BetterGenshinImpact.Core.Simulator.Extensions;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using System.Globalization;
|
||||
using BetterGenshinImpact.Helpers;
|
||||
using System.Text.RegularExpressions;
|
||||
using BetterGenshinImpact.GameTask.Model.Area;
|
||||
using System.Collections.Generic;
|
||||
using Fischless.WindowsInput;
|
||||
using OpenCvSharp;
|
||||
using System.Linq;
|
||||
using BetterGenshinImpact.Core.Recognition.OpenCv;
|
||||
using BetterGenshinImpact.Core.Recognition.OCR;
|
||||
using BetterGenshinImpact.GameTask.Common;
|
||||
using BetterGenshinImpact.GameTask.Common.Job;
|
||||
using BetterGenshinImpact.GameTask.Model.GameUI;
|
||||
|
||||
namespace BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
|
||||
@@ -46,19 +48,21 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
|
||||
private readonly string[] numOfStarLocalizedString;
|
||||
|
||||
private readonly string? regularExpression;
|
||||
private readonly string? javaScript;
|
||||
|
||||
private readonly int? maxNumToCheck;
|
||||
|
||||
private readonly bool returnToMainUi = true;
|
||||
|
||||
public AutoArtifactSalvageTask(int star, string? regularExpression = null, int? maxNumToCheck = null)
|
||||
private readonly CultureInfo cultureInfo;
|
||||
|
||||
public AutoArtifactSalvageTask(int star, string? javaScript = null, int? maxNumToCheck = null)
|
||||
{
|
||||
this.star = star;
|
||||
this.regularExpression = regularExpression;
|
||||
this.javaScript = javaScript;
|
||||
this.maxNumToCheck = maxNumToCheck;
|
||||
IStringLocalizer<AutoArtifactSalvageTask> stringLocalizer = App.GetService<IStringLocalizer<AutoArtifactSalvageTask>>() ?? throw new NullReferenceException();
|
||||
CultureInfo cultureInfo = new CultureInfo(TaskContext.Instance().Config.OtherConfig.GameCultureInfoName);
|
||||
this.cultureInfo = new CultureInfo(TaskContext.Instance().Config.OtherConfig.GameCultureInfoName);
|
||||
quickSelectLocalizedString = stringLocalizer.WithCultureGet(cultureInfo, "快速选择");
|
||||
numOfStarLocalizedString =
|
||||
[
|
||||
@@ -237,7 +241,7 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
{
|
||||
logger.LogInformation("完成{Star}星圣遗物快速分解", star);
|
||||
await Delay(400, ct);
|
||||
if (regularExpression != null)
|
||||
if (javaScript != null)
|
||||
{
|
||||
input.Mouse.LeftButtonClick();
|
||||
await Delay(1000, ct);
|
||||
@@ -254,9 +258,9 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
}
|
||||
|
||||
// 分解5星
|
||||
if (regularExpression != null)
|
||||
if (javaScript != null)
|
||||
{
|
||||
await Salvage5Star(this.regularExpression, this.maxNumToCheck ?? throw new ArgumentException($"{nameof(this.maxNumToCheck)}不能为空"));
|
||||
await Salvage5Star(this.javaScript, this.maxNumToCheck ?? throw new ArgumentException($"{nameof(this.maxNumToCheck)}不能为空"));
|
||||
logger.LogInformation("筛选完毕,请复查并手动分解");
|
||||
}
|
||||
else
|
||||
@@ -270,7 +274,7 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Salvage5Star(string regularExpression, int maxNumToCheck)
|
||||
private async Task Salvage5Star(string javaScript, int maxNumToCheck)
|
||||
{
|
||||
int count = maxNumToCheck;
|
||||
|
||||
@@ -291,15 +295,15 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
if (GetArtifactStatus(itemRegion1.SrcMat) == ArtifactStatus.Selected)
|
||||
{
|
||||
using ImageRegion card = ra1.DeriveCrop(new Rect((int)(ra1.Width * 0.70), (int)(ra1.Width * 0.055), (int)(ra1.Width * 0.24), (int)(ra1.Width * 0.29)));
|
||||
string affixes = GetArtifactAffixes(card.SrcMat, OcrFactory.Paddle);
|
||||
ArtifactStat artifact = GetArtifactStat(card.SrcMat, OcrFactory.Paddle, this.cultureInfo, out string allText);
|
||||
|
||||
if (IsMatchRegularExpression(affixes, regularExpression, out string msg))
|
||||
if (IsMatchJavaScript(artifact, javaScript))
|
||||
{
|
||||
logger.LogInformation(message: msg);
|
||||
// logger.LogInformation(message: msg);
|
||||
}
|
||||
else
|
||||
{
|
||||
itemRegion.Click();
|
||||
itemRegion.Click(); // 反选取消
|
||||
await Delay(100, ct);
|
||||
}
|
||||
}
|
||||
@@ -314,6 +318,45 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否匹配JavaScript的计算结果
|
||||
/// </summary>
|
||||
/// <param name="artifact">作为JS入参,JS使用“ArtifactStat”获取</param>
|
||||
/// <param name="javaScript"></param>
|
||||
/// <param name="engine">由调用者控制生命周期</param>
|
||||
/// <returns>是否匹配。取JS的“Output”作为出参</returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
/// <exception cref="Exception"></exception>
|
||||
public static bool IsMatchJavaScript(ArtifactStat artifact, string javaScript)
|
||||
{
|
||||
using V8ScriptEngine engine = new V8ScriptEngine(V8ScriptEngineFlags.UseCaseInsensitiveMemberBinding | V8ScriptEngineFlags.DisableGlobalMembers);
|
||||
try
|
||||
{
|
||||
// 传入输入参数
|
||||
engine.Script.ArtifactStat = artifact;
|
||||
|
||||
// 执行JavaScript代码
|
||||
engine.Execute(javaScript);
|
||||
|
||||
// 检查是否有输出
|
||||
if (!engine.Script.propertyIsEnumerable("Output"))
|
||||
{
|
||||
throw new InvalidOperationException("JavaScript没有设置Output输出");
|
||||
}
|
||||
|
||||
if (engine.Script.Output is not bool)
|
||||
{
|
||||
throw new InvalidOperationException("JavaScript的Output输出不是布尔类型");
|
||||
}
|
||||
|
||||
return (bool)engine.Script.Output;
|
||||
}
|
||||
catch (ScriptEngineException ex)
|
||||
{
|
||||
throw new Exception($"JavaScript execution error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsMatchRegularExpression(string affixes, string regularExpression, out string msg)
|
||||
{
|
||||
Match match = Regex.Match(affixes, regularExpression);
|
||||
@@ -336,10 +379,140 @@ public class AutoArtifactSalvageTask : ISoloTask
|
||||
return match.Success;
|
||||
}
|
||||
|
||||
public static string GetArtifactAffixes(Mat src, IOcrService ocrService)
|
||||
public static ArtifactStat GetArtifactStat(Mat src, IOcrService ocrService, CultureInfo cultureInfo, out string allText)
|
||||
{
|
||||
var ocrResult = ocrService.OcrResult(src);
|
||||
return ocrResult.Text;
|
||||
allText = ocrResult.Text;
|
||||
var lines = ocrResult.Text.Split('\n');
|
||||
string percentStr = "%";
|
||||
|
||||
// 名称
|
||||
string name = lines[0];
|
||||
|
||||
#region 主词条
|
||||
var defaultMainAffix = ArtifactAffix.DefaultStrDic.Select(kvp => kvp.Value).Distinct();
|
||||
string mainAffixTypeLine = lines.Single(l => defaultMainAffix.Contains(l));
|
||||
ArtifactAffixType mainAffixType = ArtifactAffix.DefaultStrDic.First(kvp => kvp.Value == mainAffixTypeLine).Key;
|
||||
string mainAffixValueLine = lines.Select(l =>
|
||||
{
|
||||
string pattern = @"^(\d+\.?\d*)(%?)$";
|
||||
pattern = pattern.Replace("%", percentStr); // 这样一行一行写只是为了IDE能保持正则字符串高亮
|
||||
Match match = Regex.Match(l, pattern);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(l => l != null).Cast<string>().Single();
|
||||
if (!float.TryParse(mainAffixValueLine, NumberStyles.Any, cultureInfo, out float value))
|
||||
{
|
||||
throw new Exception($"未识别的主词条数值:{mainAffixValueLine}");
|
||||
}
|
||||
ArtifactAffix mainAffix = new ArtifactAffix(mainAffixType, value);
|
||||
#endregion
|
||||
|
||||
#region 副词条
|
||||
ArtifactAffix[] minorAffixes = lines.Select(l =>
|
||||
{
|
||||
string pattern = @"^[•·]?([^+]+)\+(\d+\.?\d*)(%?)$";
|
||||
pattern = pattern.Replace("%", percentStr);
|
||||
Match match = Regex.Match(l, pattern);
|
||||
if (match.Success)
|
||||
{
|
||||
ArtifactAffixType artifactAffixType;
|
||||
var dic = ArtifactAffix.DefaultStrDic;
|
||||
|
||||
if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.ATK]))
|
||||
{
|
||||
if (String.IsNullOrEmpty(match.Groups[3].Value))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.ATK;
|
||||
}
|
||||
else
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.ATKPercent;
|
||||
}
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.DEF]))
|
||||
{
|
||||
if (String.IsNullOrEmpty(match.Groups[3].Value))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.DEF;
|
||||
}
|
||||
else
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.DEFPercent;
|
||||
}
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.HP]))
|
||||
{
|
||||
if (String.IsNullOrEmpty(match.Groups[3].Value))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.HP;
|
||||
}
|
||||
else
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.HPPercent;
|
||||
}
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.CRITRate]))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.CRITRate;
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.CRITDMG]))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.CRITDMG;
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.ElementalMastery]))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.ElementalMastery;
|
||||
}
|
||||
else if (match.Groups[1].Value.Contains(dic[ArtifactAffixType.EnergyRecharge]))
|
||||
{
|
||||
artifactAffixType = ArtifactAffixType.EnergyRecharge;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"未识别的副词条:{match.Groups[1].Value}");
|
||||
}
|
||||
|
||||
if (!float.TryParse(match.Groups[2].Value, NumberStyles.Any, cultureInfo, out float value))
|
||||
{
|
||||
throw new Exception($"未识别的副词条数值:{match.Groups[2].Value}");
|
||||
}
|
||||
return new ArtifactAffix(artifactAffixType, value);
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(a => a != null).Cast<ArtifactAffix>().ToArray();
|
||||
#endregion
|
||||
|
||||
#region 等级
|
||||
string levelLine = lines.Select(l =>
|
||||
{
|
||||
string pattern = @"^\+(\d*)$";
|
||||
Match match = Regex.Match(l, pattern);
|
||||
if (match.Success)
|
||||
{
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(l => l != null).Cast<string>().Single();
|
||||
if (!int.TryParse(levelLine, out int level) || level < 0 || level > 20)
|
||||
{
|
||||
throw new Exception($"未识别的等级:{levelLine}");
|
||||
}
|
||||
#endregion
|
||||
|
||||
return new ArtifactStat(name, mainAffix, minorAffixes, level);
|
||||
}
|
||||
|
||||
public static ArtifactStatus GetArtifactStatus(Mat src)
|
||||
|
||||
@@ -2291,7 +2291,11 @@
|
||||
Grid.Column="0"
|
||||
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
|
||||
TextWrapping="Wrap">
|
||||
指定匹配表达式逐一筛选分解,支持5星圣遗物
|
||||
指定匹配表达式逐一筛选分解,支持5星圣遗物 -
|
||||
<Hyperlink Command="{Binding GoToArtifactSalvageUrlCommand}"
|
||||
Foreground="{ui:ThemeResource TextFillColorSecondaryBrush}">
|
||||
点击查看使用教程
|
||||
</Hyperlink>
|
||||
</ui:TextBlock>
|
||||
<controls:TwoStateButton Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
@@ -2343,20 +2347,28 @@
|
||||
<ui:TextBlock Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
FontTypography="Body"
|
||||
Text="正则表达式"
|
||||
TextWrapping="Wrap" />
|
||||
TextWrapping="Wrap">
|
||||
JavaScript -
|
||||
<Hyperlink Command="{Binding GoToArtifactSalvageUrlCommand}"
|
||||
Foreground="{ui:ThemeResource TextFillColorSecondaryBrush}">
|
||||
点击查看使用教程
|
||||
</Hyperlink>
|
||||
</ui:TextBlock>
|
||||
<ui:TextBlock Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Foreground="{ui:ThemeResource TextFillColorTertiaryBrush}"
|
||||
Text="只要满足的圣遗物都会被选中"
|
||||
TextWrapping="Wrap" />
|
||||
TextWrapping="Wrap">
|
||||
只要满足的圣遗物都会被选中
|
||||
<LineBreak/>
|
||||
JS接受ArtifactStat作为入参;应对Output赋值一个布尔值作为返回
|
||||
</ui:TextBlock>
|
||||
<ui:TextBox Grid.Row="0"
|
||||
Grid.RowSpan="2"
|
||||
Grid.Column="1"
|
||||
MinWidth="180"
|
||||
MaxWidth="800"
|
||||
Margin="0,0,36,0"
|
||||
Text="{Binding Config.AutoArtifactSalvageConfig.RegularExpression, Mode=TwoWay}"
|
||||
Text="{Binding Config.AutoArtifactSalvageConfig.JavaScript, Mode=TwoWay}"
|
||||
TextWrapping="Wrap" Cursor="IBeam" />
|
||||
</Grid>
|
||||
<Grid Margin="16">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using BetterGenshinImpact.Core.Recognition.OCR;
|
||||
using BetterGenshinImpact.Core.Recognition.OCR;
|
||||
using BetterGenshinImpact.GameTask;
|
||||
using BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
using BetterGenshinImpact.GameTask.Common;
|
||||
using BetterGenshinImpact.GameTask.Model.Area;
|
||||
using BetterGenshinImpact.Helpers.Extensions;
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
|
||||
namespace BetterGenshinImpact.View.Windows;
|
||||
@@ -13,14 +14,14 @@ public partial class OcrDialog
|
||||
private readonly double yRatio;
|
||||
private readonly double widthRatio;
|
||||
private readonly double heightRatio;
|
||||
private readonly string? regularExpression;
|
||||
public OcrDialog(double xRatio, double yRatio, double widthRatio, double heightRatio, string title, string? regularExpression = null)
|
||||
private readonly string? javaScript;
|
||||
public OcrDialog(double xRatio, double yRatio, double widthRatio, double heightRatio, string title, string? javaScript = null)
|
||||
{
|
||||
this.xRatio = xRatio;
|
||||
this.yRatio = yRatio;
|
||||
this.widthRatio = widthRatio;
|
||||
this.heightRatio = heightRatio;
|
||||
this.regularExpression = regularExpression;
|
||||
this.javaScript = javaScript;
|
||||
|
||||
InitializeComponent();
|
||||
|
||||
@@ -36,11 +37,12 @@ public partial class OcrDialog
|
||||
|
||||
this.Screenshot.Source = bitmapImage;
|
||||
|
||||
this.TxtRecognized.Text = OcrFactory.Paddle.OcrResult(card.SrcMat).Text;
|
||||
if (this.regularExpression != null)
|
||||
ArtifactStat artifact = AutoArtifactSalvageTask.GetArtifactStat(card.SrcMat, OcrFactory.Paddle, new CultureInfo(TaskContext.Instance().Config.OtherConfig.GameCultureInfoName), out string allText);
|
||||
this.TxtRecognized.Text = allText;
|
||||
if (this.javaScript != null)
|
||||
{
|
||||
AutoArtifactSalvageTask.IsMatchRegularExpression(this.TxtRecognized.Text, this.regularExpression, out string msg);
|
||||
this.RegexResult.Text = msg;
|
||||
bool isMatch = AutoArtifactSalvageTask.IsMatchJavaScript(artifact, this.javaScript);
|
||||
this.RegexResult.Text = isMatch ? "匹配" : "不匹配";
|
||||
}
|
||||
this.UpdateLayout();
|
||||
}
|
||||
|
||||
@@ -527,14 +527,20 @@ public partial class TaskSettingsPageViewModel : ViewModel
|
||||
{
|
||||
SwitchArtifactSalvageEnabled = true;
|
||||
await new TaskRunner()
|
||||
.RunSoloTaskAsync(new AutoArtifactSalvageTask(int.Parse(Config.AutoArtifactSalvageConfig.MaxArtifactStar), Config.AutoArtifactSalvageConfig.RegularExpression, Config.AutoArtifactSalvageConfig.MaxNumToCheck));
|
||||
.RunSoloTaskAsync(new AutoArtifactSalvageTask(int.Parse(Config.AutoArtifactSalvageConfig.MaxArtifactStar), Config.AutoArtifactSalvageConfig.JavaScript, Config.AutoArtifactSalvageConfig.MaxNumToCheck));
|
||||
SwitchArtifactSalvageEnabled = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OnGoToArtifactSalvageUrlAsync()
|
||||
{
|
||||
await Launcher.LaunchUriAsync(new Uri("https://bettergi.com/feats/task/artifactSalvage.html"));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OnOpenArtifactSalvageTestOCRWindow()
|
||||
{
|
||||
OcrDialog ocrDialog = new OcrDialog(0.70, 0.098, 0.24, 0.52, "圣遗物分解", this.Config.AutoArtifactSalvageConfig.RegularExpression);
|
||||
OcrDialog ocrDialog = new OcrDialog(0.70, 0.098, 0.24, 0.52, "圣遗物分解", this.Config.AutoArtifactSalvageConfig.JavaScript);
|
||||
ocrDialog.ShowDialog();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
using BetterGenshinImpact.GameTask.AutoArtifactSalvage;
|
||||
using BetterGenshinImpact.GameTask.Model.GameUI;
|
||||
using BetterGenshinImpact.UnitTest.CoreTests.RecognitionTests.OCRTests;
|
||||
using OpenCvSharp;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using static BetterGenshinImpact.GameTask.AutoArtifactSalvage.AutoArtifactSalvageTask;
|
||||
|
||||
namespace BetterGenshinImpact.UnitTest.GameTaskTests.AutoArtifactSalvageTests
|
||||
@@ -73,7 +73,7 @@ namespace BetterGenshinImpact.UnitTest.GameTaskTests.AutoArtifactSalvageTests
|
||||
private static ConcurrentDictionary<string, string> PaddleResultDic { get; } = new ConcurrentDictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// 测试获取分解圣遗物界面右侧圣遗物的词缀等属性,结果应正确
|
||||
/// 测试获取分解圣遗物界面右侧圣遗物的词缀等属性,使用正则表达式,结果应正确
|
||||
/// </summary>
|
||||
/// <param name="screenshot"></param>
|
||||
[Theory]
|
||||
@@ -88,15 +88,17 @@ namespace BetterGenshinImpact.UnitTest.GameTaskTests.AutoArtifactSalvageTests
|
||||
[InlineData(@"ArtifactAffixes.png", @"攻击力\+[\d]*\n", false)]
|
||||
[InlineData(@"ArtifactAffixes.png", @"防御力\+[\d.]*%\n", false)]
|
||||
[InlineData(@"ArtifactAffixes.png", @"防御力\+[\d]*\n")]
|
||||
public void GetArtifactAffixes_ShouldBeRight(string screenshot, string pattern, bool isMatch = true)
|
||||
public void GetArtifactStat_RegexPatternShouldBeRight(string screenshot, string pattern, bool isMatch = true)
|
||||
{
|
||||
//
|
||||
CultureInfo cultureInfo = new CultureInfo("zh-Hans");
|
||||
|
||||
//
|
||||
string result = PaddleResultDic.GetOrAdd(screenshot, screenshot_ =>
|
||||
{
|
||||
using Mat mat = new Mat(@$"..\..\..\Assets\AutoArtifactSalvage\{screenshot_}");
|
||||
return AutoArtifactSalvageTask.GetArtifactAffixes(mat, paddle.Get());
|
||||
GetArtifactStat(mat, paddle.Get(), cultureInfo, out string allText);
|
||||
return allText;
|
||||
});
|
||||
|
||||
//
|
||||
@@ -109,5 +111,57 @@ namespace BetterGenshinImpact.UnitTest.GameTaskTests.AutoArtifactSalvageTests
|
||||
Assert.DoesNotMatch(pattern, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试获取分解圣遗物界面右侧圣遗物的各种结构化信息,结果应正确
|
||||
/// </summary>
|
||||
/// <param name="screenshot"></param>
|
||||
[Theory]
|
||||
[InlineData(@"ArtifactAffixes.png")]
|
||||
public void GetArtifactStat_AffixesShouldBeRight(string screenshot)
|
||||
{
|
||||
//
|
||||
CultureInfo cultureInfo = new CultureInfo("zh-Hans");
|
||||
|
||||
//
|
||||
using Mat mat = new Mat(@$"..\..\..\Assets\AutoArtifactSalvage\{screenshot}");
|
||||
ArtifactStat artifact = GetArtifactStat(mat, paddle.Get(), cultureInfo, out string _);
|
||||
|
||||
//
|
||||
Assert.Equal("异种的期许", artifact.Name);
|
||||
Assert.True(artifact.MainAffix.Type == ArtifactAffixType.HP);
|
||||
Assert.True(artifact.MainAffix.Value == 717f);
|
||||
Assert.Contains(artifact.MinorAffixes, a => a.Type == ArtifactAffixType.ElementalMastery && a.Value == 16f);
|
||||
Assert.Contains(artifact.MinorAffixes, a => a.Type == ArtifactAffixType.EnergyRecharge && a.Value == 6.5f);
|
||||
Assert.Contains(artifact.MinorAffixes, a => a.Type == ArtifactAffixType.ATKPercent && a.Value == 5.8f);
|
||||
Assert.Contains(artifact.MinorAffixes, a => a.Type == ArtifactAffixType.DEF && a.Value == 23f);
|
||||
Assert.True(artifact.Level == 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"ArtifactAffixes.png", @"(async function (artifact) {
|
||||
var hasATK = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'ATK');
|
||||
var hasDEF = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'DEF');
|
||||
Output = hasATK && hasDEF;
|
||||
})(ArtifactStat);", false)]
|
||||
[InlineData(@"ArtifactAffixes.png", @"(async function (artifact) {
|
||||
var level = artifact.Level;
|
||||
var hasATKPercent = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'ATKPercent');
|
||||
var hasDEF = Array.from(artifact.MinorAffixes).some(affix => affix.Type == 'DEF');
|
||||
Output = level == 0 && hasATKPercent && hasDEF;
|
||||
})(ArtifactStat);", true)]
|
||||
public void IsMatchJavaScript_JSShouldBeRight(string screenshot, string js, bool expected)
|
||||
{
|
||||
//
|
||||
CultureInfo cultureInfo = new CultureInfo("zh-Hans");
|
||||
|
||||
//
|
||||
using Mat mat = new Mat(@$"..\..\..\Assets\AutoArtifactSalvage\{screenshot}");
|
||||
ArtifactStat artifact = GetArtifactStat(mat, paddle.Get(), cultureInfo, out string _);
|
||||
bool result = IsMatchJavaScript(artifact, js);
|
||||
|
||||
//
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user