maipulse: 脱敏存档
- Python 后端 + 单页前端 + Android(Chaquopy) 壳 - 曲库 1343 首 / 称号 2800 个 - 机台身份与店铺信息已抹除,字段留空 - 打包脚本含 16KB 页对齐与自动脱敏
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# 构建产物
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
build/
|
||||
.gradle/
|
||||
app/build/
|
||||
local.properties
|
||||
|
||||
# 私钥与签名
|
||||
*.keystore
|
||||
*.jks
|
||||
*.p12
|
||||
|
||||
# 用户数据:绝不入库
|
||||
maipulse/data/accounts.json
|
||||
maipulse/data/shops.json
|
||||
maipulse/data/shop_current.json
|
||||
maipulse/data/covers/
|
||||
maipulse/data/_backup/
|
||||
maipulse/config.filled.json
|
||||
maipulse/logs/
|
||||
astral/data/
|
||||
astral/logs/
|
||||
astral/config.py
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
@@ -0,0 +1,72 @@
|
||||
# maipulse
|
||||
|
||||
舞萌 DX 国服(SDGB)的本地发包台。Flask + 单页前端,安卓壳用 Chaquopy 把 Python 打进 APK。
|
||||
|
||||
**这个仓库是脱敏存档**:所有机台身份(标题服务器地址、AES 密钥与 IV、混淆盐)和店铺信息都已经抹掉,
|
||||
字段留空,运行时从配置文件读。想用的话自己填——见下面的「配置」。
|
||||
|
||||
## 目录
|
||||
|
||||
```
|
||||
maipulse/ Python 后端 + 前端单页
|
||||
app.py Flask 入口,所有 HTTP 接口
|
||||
core/ 业务模块(session / b50 / unlock / items / settle ...)
|
||||
html/index.html 整个界面(CSS/JS 全内联)
|
||||
data/master/ 曲库与道具索引(公开数据)
|
||||
tools/ 打包、更新合并、脱敏脚本
|
||||
astral/ SDGB 协议实现(脱敏副本,config.py 需要自己生成)
|
||||
android/ Android 壳工程(Gradle + MainActivity)
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
`astral/config.py` 和 `astral/api/MaimaiNet.py` 里的关键字段全部为空:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `CLIENT_ID` | 11 位机台 ID |
|
||||
| `PLACE_ID` / `REGION_ID` | 店铺 / 区域 ID |
|
||||
| `PLACE_NAME` | 店铺名,只用于界面显示 |
|
||||
| `BASE_URL` | 标题服务器地址 |
|
||||
| `AES_KEY` / `AES_IV` | 32 / 16 字节 |
|
||||
| `OBFUSCATE_PARAM` | 8 位混淆盐 |
|
||||
|
||||
填完在设置页点「导入配置」加载 JSON,或者直接改这两个文件后重启。
|
||||
|
||||
## 跑起来
|
||||
|
||||
```bash
|
||||
python app.py # http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
依赖:`flask`、`requests`、`pycryptodome`。
|
||||
|
||||
## 打包 APK
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File maipulse\tools\build_apk.ps1
|
||||
```
|
||||
|
||||
脚本会:清空旧产物 → 同步资源 → 脱敏 → 编译 → `zipalign -P 16`(16KB 页对齐,Android 15+ 要求)→
|
||||
重签 → 放到桌面。
|
||||
|
||||
需要自己准备一个 debug keystore 放在 `android/` 下,并在 `android/app/build.gradle` 里改路径。
|
||||
**keystore 不进仓库**,它带着私钥。
|
||||
|
||||
## 数据更新
|
||||
|
||||
官方更新包(`SDGB_A0xx_*`)到手后:
|
||||
|
||||
```bash
|
||||
python maipulse/tools/apply_update.py "路径/包目录" --dry # 先看差异
|
||||
python maipulse/tools/apply_update.py "路径/包目录" # 真正写入
|
||||
```
|
||||
|
||||
只做增量合并,写盘前自动备份到 `data/_backup/`。
|
||||
|
||||
## 脱敏说明
|
||||
|
||||
`maipulse/tools/blank_secrets.py` 是打包时自动跑的那一步:从主仓读出真实值,
|
||||
在副本里全部替换成占位符,然后自己复核一遍,有残留就 return 2 让打包失败。
|
||||
|
||||
这个仓库里跑过同样的流程,30 处替换,复核无残留。
|
||||
@@ -0,0 +1,65 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.chaquo.python'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.maipulse.app'
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.maipulse.app"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
ndk {
|
||||
abiFilters "arm64-v8a"
|
||||
}
|
||||
|
||||
// Chaquopy 16 的 DSL 挂在 android.defaultConfig 下面
|
||||
python {
|
||||
buildPython "C:/Users/20558/AppData/Local/Programs/Python/Python38/python.exe"
|
||||
pip {
|
||||
install "flask"
|
||||
install "requests"
|
||||
install "pycryptodome"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 固定 keystore:AGP 默认会在 $HOME/.android 下自动生成一个,
|
||||
// 而 zipalign 之后我们要用 apksigner 重签。两边不是同一个 key 的话,
|
||||
// 出来的包签名不一样,用户必须卸载才能覆盖安装。
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("../maipulse-debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
androidResources {
|
||||
noCompress 'json', 'png'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core:1.12.0'
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="maipulse"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,397 @@
|
||||
package com.maipulse.app;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.DownloadManager;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.ValueCallback;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebChromeClient;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.chaquo.python.Python;
|
||||
import com.chaquo.python.android.AndroidPlatform;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* maipulse 的 Android 外壳。
|
||||
*
|
||||
* 流程:复制 assets/app -> filesDir/app(assets 只读,Flask 要写 logs 与缓存),
|
||||
* 起 Chaquopy 跑 Flask 绑 127.0.0.1:8787,端口通了再把 WebView 指过去。
|
||||
*
|
||||
* 额外给了网页一个 Maipulse.installApk(url):走系统 DownloadManager 下 APK,
|
||||
* 下完弹安装器,这样更新不用让用户自己去找文件。
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
|
||||
private static final String TAG = "maipulse";
|
||||
private static final int PORT = 8787;
|
||||
|
||||
private WebView web;
|
||||
private TextView tip;
|
||||
private static final int REQ_PICK_FILE = 1001;
|
||||
private ValueCallback<Uri[]> filePathCallback;
|
||||
private long pendingDownloadId = -1;
|
||||
private BroadcastReceiver downloadReceiver;
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
FrameLayout root = new FrameLayout(this);
|
||||
root.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
|
||||
web = new WebView(this);
|
||||
web.setBackgroundColor(Color.parseColor("#EEF2F9"));
|
||||
WebSettings s = web.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setAllowFileAccess(true);
|
||||
s.setDatabaseEnabled(true);
|
||||
s.setMediaPlaybackRequiresUserGesture(false);
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
s.setCacheMode(WebSettings.LOAD_DEFAULT);
|
||||
// 禁掉双指缩放与双击放大(网页里能放大就是这里没设)
|
||||
s.setSupportZoom(false);
|
||||
s.setBuiltInZoomControls(false);
|
||||
s.setDisplayZoomControls(false);
|
||||
s.setTextZoom(100);
|
||||
// 长按不再弹出「复制/分享」这类浏览器菜单
|
||||
web.setLongClickable(false);
|
||||
web.setHapticFeedbackEnabled(false);
|
||||
web.setWebViewClient(new WebViewClient());
|
||||
|
||||
// 网页里的 <input type=file> 走 WebChromeClient.onShowFileChooser,挂在
|
||||
// WebViewClient 上编译不过,必须单独设一个 chrome client。
|
||||
web.setWebChromeClient(new WebChromeClient() {
|
||||
@Override
|
||||
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback,
|
||||
FileChooserParams params) {
|
||||
if (filePathCallback != null) filePathCallback.onReceiveValue(null);
|
||||
filePathCallback = callback;
|
||||
try {
|
||||
Intent i = params.createIntent();
|
||||
i.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
startActivityForResult(i, REQ_PICK_FILE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
filePathCallback = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
web.addJavascriptInterface(new Bridge(), "Maipulse");
|
||||
|
||||
tip = new TextView(this);
|
||||
tip.setText("正在启动服务…");
|
||||
tip.setTextColor(Color.parseColor("#5A6A82"));
|
||||
tip.setTextSize(15);
|
||||
tip.setPadding(48, 48, 48, 48);
|
||||
|
||||
root.addView(web, new FrameLayout.LayoutParams(-1, -1));
|
||||
root.addView(tip, new FrameLayout.LayoutParams(-1, -1));
|
||||
setContentView(root);
|
||||
|
||||
registerDownloadReceiver();
|
||||
new Thread(this::bootPython, "maipulse-boot").start();
|
||||
new Thread(this::waitAndLoad, "maipulse-wait").start();
|
||||
}
|
||||
|
||||
/* ---------------- 网页可调的接口 ---------------- */
|
||||
|
||||
public class Bridge {
|
||||
/** 下载并安装新版本。url 由网页的更新源给。 */
|
||||
@JavascriptInterface
|
||||
public void installApk(String url) {
|
||||
runOnUiThread(() -> startDownload(url));
|
||||
}
|
||||
|
||||
/** 当前 App 版本号,网页可以拿来显示。 */
|
||||
@JavascriptInterface
|
||||
public String appVersion() {
|
||||
try {
|
||||
return getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
||||
} catch (Exception e) {
|
||||
return "1.0";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startDownload(String url) {
|
||||
if (url == null || !url.startsWith("http")) {
|
||||
Toast.makeText(this, "更新地址不对", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||
if (dir != null) {
|
||||
for (File f : dir.listFiles()) {
|
||||
if (f.getName().endsWith(".apk")) f.delete();
|
||||
}
|
||||
}
|
||||
DownloadManager.Request req = new DownloadManager.Request(Uri.parse(url));
|
||||
req.setTitle("maipulse 新版本");
|
||||
req.setDescription("下载完成后会弹安装");
|
||||
req.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
|
||||
req.setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, "maipulse-update.apk");
|
||||
req.setMimeType("application/vnd.android.package-archive");
|
||||
DownloadManager dm = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
|
||||
pendingDownloadId = dm.enqueue(req);
|
||||
Toast.makeText(this, "开始下载,完成后会弹安装", Toast.LENGTH_LONG).show();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "download failed", e);
|
||||
Toast.makeText(this, "下载失败:" + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void registerDownloadReceiver() {
|
||||
downloadReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
public void onReceive(Context ctx, Intent intent) {
|
||||
if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) return;
|
||||
long id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
|
||||
if (id != pendingDownloadId) return;
|
||||
File dir = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
|
||||
if (dir == null) return;
|
||||
File apk = new File(dir, "maipulse-update.apk");
|
||||
if (apk.exists()) installApkFile(apk);
|
||||
}
|
||||
};
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(downloadReceiver, filter, Context.RECEIVER_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(downloadReceiver, filter);
|
||||
}
|
||||
}
|
||||
|
||||
private void installApkFile(File apk) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
&& !getPackageManager().canRequestPackageInstalls()) {
|
||||
// 没有「安装未知应用」权限,跳设置页让用户开
|
||||
Intent s = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:" + getPackageName()));
|
||||
startActivity(s);
|
||||
Toast.makeText(this, "允许安装后回到 App 再点一次更新", Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
Uri uri = FileProvider.getUriForFile(this, getPackageName() + ".fileprovider", apk);
|
||||
Intent i = new Intent(Intent.ACTION_VIEW);
|
||||
i.setDataAndType(uri, "application/vnd.android.package-archive");
|
||||
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(i);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "install failed", e);
|
||||
Toast.makeText(this, "拉起安装器失败:" + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Python 启动 ---------------- */
|
||||
|
||||
private void bootPython() {
|
||||
try {
|
||||
File files = getFilesDir();
|
||||
File appDir = new File(files, "app");
|
||||
File stamp = new File(files, "asset.rev");
|
||||
// 内置资源版本号:打包时由 build_apk.ps1 写进 assets/app/asset.rev,
|
||||
// 每次打包都会变,所以数据更新后一定能触发重建(手写常量太容易忘)。
|
||||
String want = "";
|
||||
try (InputStream in = getAssets().open("app/asset.rev")) {
|
||||
byte[] b = new byte[128];
|
||||
int n = in.read(b);
|
||||
if (n > 0) want = new String(b, 0, n, "UTF-8").trim();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
String have = "";
|
||||
try {
|
||||
if (stamp.exists()) {
|
||||
byte[] b = new byte[(int) stamp.length()];
|
||||
try (InputStream in = new FileInputStream(stamp)) {
|
||||
int n = in.read(b);
|
||||
if (n > 0) have = new String(b, 0, n, "UTF-8").trim();
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
boolean broken = !appDir.exists()
|
||||
|| !new File(appDir, "app.py").exists()
|
||||
|| !new File(appDir, "data/master.json").exists();
|
||||
if (broken || want.isEmpty() || !want.equals(have)) {
|
||||
tip("正在释放内置资源");
|
||||
// 用户导入的机台配置要保住,其余整目录重建(否则旧资源永远不更新)
|
||||
byte[] conf = null;
|
||||
File confFile = new File(new File(appDir, "astral"), "config.py");
|
||||
if (confFile.exists() && confFile.length() > 0) {
|
||||
conf = new byte[(int) confFile.length()];
|
||||
try (InputStream in = new FileInputStream(confFile)) {
|
||||
int n = in.read(conf);
|
||||
if (n <= 0) conf = null;
|
||||
}
|
||||
}
|
||||
deleteTree(appDir);
|
||||
copyAssetDir("app", appDir);
|
||||
if (conf != null) {
|
||||
File cf = new File(new File(appDir, "astral"), "config.py");
|
||||
File parent = cf.getParentFile();
|
||||
if (parent != null && !parent.exists()) parent.mkdirs();
|
||||
try (OutputStream out = new FileOutputStream(cf)) {
|
||||
out.write(conf);
|
||||
}
|
||||
}
|
||||
try (OutputStream out = new FileOutputStream(stamp)) {
|
||||
out.write(want.getBytes("UTF-8"));
|
||||
}
|
||||
}
|
||||
tip("正在加载 Python 运行时…");
|
||||
if (!Python.isStarted()) {
|
||||
Python.start(new AndroidPlatform(this));
|
||||
}
|
||||
tip("正在启动本地服务…");
|
||||
Python.getInstance().getModule("android_boot").callAttr("boot", files.getAbsolutePath());
|
||||
} catch (Throwable t) {
|
||||
Log.e(TAG, "boot failed", t);
|
||||
tip("启动失败:" + t);
|
||||
}
|
||||
}
|
||||
|
||||
private void tip(final String text) {
|
||||
runOnUiThread(() -> tip.setText(text));
|
||||
}
|
||||
|
||||
private void deleteTree(File f) {
|
||||
if (f == null || !f.exists()) return;
|
||||
if (f.isDirectory()) {
|
||||
File[] kids = f.listFiles();
|
||||
if (kids != null) {
|
||||
for (File k : kids) deleteTree(k);
|
||||
}
|
||||
}
|
||||
f.delete();
|
||||
}
|
||||
|
||||
private void copyAssetDir(String src, File dst) throws IOException {
|
||||
AssetManager am = getAssets();
|
||||
String[] list = am.list(src);
|
||||
if (list == null || list.length == 0) {
|
||||
copyAssetFile(src, dst);
|
||||
return;
|
||||
}
|
||||
if (!dst.exists() && !dst.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + dst);
|
||||
}
|
||||
for (String name : list) {
|
||||
copyAssetDir(src + "/" + name, new File(dst, name));
|
||||
}
|
||||
}
|
||||
|
||||
private void copyAssetFile(String src, File dst) throws IOException {
|
||||
// 不跳过已存在文件:整目录重建后旧的残留必须被覆盖
|
||||
File parent = dst.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("mkdirs failed: " + parent);
|
||||
}
|
||||
try (InputStream in = getAssets().open(src);
|
||||
OutputStream out = new FileOutputStream(dst)) {
|
||||
byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
|
||||
}
|
||||
}
|
||||
|
||||
private void waitAndLoad() {
|
||||
for (int i = 0; i < 200; i++) {
|
||||
try (Socket sock = new Socket()) {
|
||||
sock.connect(new InetSocketAddress("127.0.0.1", PORT), 300);
|
||||
runOnUiThread(() -> {
|
||||
tip.setVisibility(View.GONE);
|
||||
web.loadUrl("http://127.0.0.1:" + PORT + "/");
|
||||
});
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
try {
|
||||
Thread.sleep(200);
|
||||
} catch (InterruptedException e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
tip("服务没有起来,看看日志");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
if (requestCode == REQ_PICK_FILE) {
|
||||
Uri[] result = null;
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
if (data.getDataString() != null) {
|
||||
result = new Uri[]{Uri.parse(data.getDataString())};
|
||||
} else if (data.getClipData() != null) {
|
||||
int n = data.getClipData().getItemCount();
|
||||
result = new Uri[n];
|
||||
for (int i = 0; i < n; i++) {
|
||||
result[i] = data.getClipData().getItemAt(i).getUri();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filePathCallback != null) {
|
||||
filePathCallback.onReceiveValue(result);
|
||||
filePathCallback = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && web != null && web.canGoBack()) {
|
||||
web.goBack();
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
try {
|
||||
if (downloadReceiver != null) unregisterReceiver(downloadReceiver);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (web != null) {
|
||||
web.destroy();
|
||||
web = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Android 启动器:把 filesDir/app 挂进 sys.path,然后跑 Flask。
|
||||
|
||||
为什么不在 assets 里直接 import:app.py 顶层会 os.makedirs(logs),
|
||||
assets 是只读的,必须用 Java 侧复制出来的可写副本。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
PORT = 8787
|
||||
|
||||
|
||||
def boot(files_dir):
|
||||
base = os.path.join(files_dir, "app")
|
||||
if base not in sys.path:
|
||||
sys.path.insert(0, base)
|
||||
astral = os.path.join(base, "astral")
|
||||
if astral not in sys.path:
|
||||
sys.path.insert(0, astral)
|
||||
os.environ["ASTRAL_DIR"] = astral
|
||||
os.environ.setdefault("MAIPULSE_PORT", str(PORT))
|
||||
os.chdir(base)
|
||||
|
||||
import app as maipulse_app # noqa: E402
|
||||
# 绑定 127.0.0.1:只有本机 WebView 能访问
|
||||
maipulse_app.app.run(host="127.0.0.1", port=PORT, debug=False,
|
||||
threaded=True, use_reloader=False)
|
||||
return "exited"
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBg" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="iconBg">#FFE9A8</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">maipulse</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:Theme.Material.Light.NoActionBar">
|
||||
<item name="android:statusBarColor">#EEF2F9</item>
|
||||
<item name="android:navigationBarColor">#FFFFFF</item>
|
||||
<item name="android:windowBackground">#EEF2F9</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="downloaded" path="Download/" />
|
||||
<external-cache-path name="cache" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id 'com.android.application' version '8.2.2' apply false
|
||||
id 'com.chaquo.python' version '16.1.0' apply false
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.9-bin.zip
|
||||
networkTimeout=60000
|
||||
validateDistributionUrl=false
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'maipulse'
|
||||
include ':app'
|
||||
@@ -0,0 +1,7 @@
|
||||
# 统一换行符,避免 Windows/Linux 来回转换把每个文件都标成 modified
|
||||
* text=auto
|
||||
*.py text eol=lf
|
||||
*.md text eol=lf
|
||||
*.cs text eol=crlf
|
||||
*.csproj text eol=crlf
|
||||
*.json text eol=lf
|
||||
@@ -0,0 +1,40 @@
|
||||
# ---- 运行产物 ----
|
||||
# 每账号一份用户数据快照(<userId>.json)
|
||||
data/
|
||||
logs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
|
||||
source/
|
||||
|
||||
# ---- 私有配置:只提交 config.py.example,不提交 config.py ----
|
||||
config.py
|
||||
|
||||
# ---- 文档单独放 docs 分支,主分支不含 ----
|
||||
docs/
|
||||
|
||||
# ---- .NET 构建产物 ----
|
||||
gamehook/bin/
|
||||
gamehook/obj/
|
||||
|
||||
# ---- netdelivery 下载目录(.app / .opt 更新包很大,不入库)----
|
||||
download/
|
||||
|
||||
# ---- get_auth_list:clientId 遍历脚本与其产物(默认不入库、不推送)----
|
||||
# 产物默认落在 data/ 下(data/ 本就已经整目录忽略);
|
||||
# authentication.* 再兜一层,防止用 --csv/--state 指到别处
|
||||
get_auth_list.py
|
||||
authentication.*
|
||||
|
||||
# ---- extra_data 用的节点 JSON(含账号数据,不入库)----
|
||||
extra*.json
|
||||
|
||||
# ---- 编辑器 / 系统 ----
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1,499 @@
|
||||
<p align="center">
|
||||
<img src="icon/Astral.png" alt="Astral" width="220">
|
||||
</p>
|
||||
|
||||
<h1 align="center">Astral</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>舞萌DX 的游戏助手</b><br>
|
||||
宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
|
||||
</p>
|
||||
|
||||
<p align="center">AI真的很好用你知道吗</p>
|
||||
|
||||
---
|
||||
|
||||
## 这是什么
|
||||
|
||||
Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
|
||||
|
||||
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
|
||||
扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
|
||||
|
||||
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
|
||||
> 每个关键常量在代码注释里都标了**源码行号**。
|
||||
|
||||
---
|
||||
|
||||
## 当前状态:v0 plus 附加功能
|
||||
|
||||
| 脚本 | 状态 |
|
||||
|---|---|
|
||||
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
|
||||
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
|
||||
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
|
||||
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
|
||||
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
|
||||
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
|
||||
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
|
||||
|
||||
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
|
||||
|
||||
---
|
||||
|
||||
## 一个教训:别把一件事分段交给 AI
|
||||
|
||||
v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结果:
|
||||
|
||||
| 项目 | Token 消耗 |
|
||||
|---|---|
|
||||
| **v0 有效开发** | **473M+** |
|
||||
| 含中途废弃的分支项目 | **1137.5M** |
|
||||
|
||||
**这 1137.5M 里有一大半是浪费掉的。**
|
||||
|
||||
真正的教训是:
|
||||
|
||||
> **不要把一个东西分段交给 AI,而是直接设定一个目标,然后让它自己跑完。**
|
||||
|
||||
分段交接会让 AI 反复丢失上下文、重复劳动、在已经解决的问题上打转 ——
|
||||
而一个清晰的目标 + 足够长的执行窗口,反而更快也更省。
|
||||
|
||||
---
|
||||
|
||||
## 项目框架
|
||||
|
||||
```
|
||||
Astral/
|
||||
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
|
||||
├── config.py.example 配置模板
|
||||
├── icon/ Logo
|
||||
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
|
||||
├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
|
||||
├── buy_ticket.py 主脚本:购买功能票
|
||||
├── use_ticket.py 主脚本:消耗当前持有的功能票
|
||||
├── reincarnate.py 主脚本:账号数据迁移(转生)
|
||||
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
|
||||
├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
|
||||
│
|
||||
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
|
||||
│ ├── __init__.py 统一导出 + 日志自动跟踪
|
||||
│ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
│ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
|
||||
│ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
|
||||
│ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
|
||||
│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/<userId>.json
|
||||
│ └── ... 40+ 个接口脚本
|
||||
│
|
||||
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
|
||||
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
|
||||
└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
|
||||
```
|
||||
|
||||
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
|
||||
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
|
||||
|
||||
**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
|
||||
|
||||
| 服务端接口 | 脚本 | 函数 |
|
||||
|---|---|---|
|
||||
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
|
||||
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
|
||||
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
|
||||
|
||||
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
|
||||
|
||||
### 分支说明
|
||||
|
||||
| 分支 | 内容 |
|
||||
|---|---|
|
||||
| **`mai`** | 主分支 —— 代码 |
|
||||
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
|
||||
|
||||
两个分支**没有共同的提交历史**,各自独立。
|
||||
|
||||
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
|
||||
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
|
||||
> 只是用工具的话**完全不需要**这个目录。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 装依赖
|
||||
|
||||
```bash
|
||||
pip install requests pycryptodome
|
||||
```
|
||||
|
||||
### 2. ★ 先填 `config.py`
|
||||
|
||||
仓库里只有模板 `config.py.example`,先复制一份:
|
||||
|
||||
```bash
|
||||
cp config.py.example config.py # Linux / macOS
|
||||
copy config.py.example config.py # Windows
|
||||
```
|
||||
|
||||
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
|
||||
|
||||
| 变量 | 填什么 |
|
||||
|---|---|
|
||||
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
|
||||
| `PLACE_ID` | 你所在店铺 ID |
|
||||
| `REGION_ID` | 你所在区域 ID |
|
||||
| `PLACE_NAME` | 店铺名 |
|
||||
| `REGION_NAME` | 区域名 |
|
||||
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
|
||||
|
||||
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
|
||||
>
|
||||
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
|
||||
|
||||
### 3. 跑自检
|
||||
|
||||
```bash
|
||||
python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
|
||||
```
|
||||
|
||||
### 主脚本一览
|
||||
|
||||
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|
||||
|---|---|---|---|
|
||||
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
|
||||
| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
|
||||
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
|
||||
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
|
||||
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
|
||||
| `netdelivery.py` | 查 / 下 `.app` / `.opt` 更新包 | 只读 | ❌ |
|
||||
| `selftest.py` | 零网络自检 | 无 | ❌ |
|
||||
|
||||
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
|
||||
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
|
||||
|
||||
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。
|
||||
|
||||
---
|
||||
|
||||
## upsert.py —— 上传一局成绩
|
||||
|
||||
```bash
|
||||
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
|
||||
python upsert.py "<二维码>"
|
||||
|
||||
# 真实上传一局成绩(★ 有副作用,不可撤销)
|
||||
python upsert.py "<二维码>" --send
|
||||
|
||||
# 常用开关
|
||||
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
|
||||
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
|
||||
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
|
||||
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
|
||||
```
|
||||
|
||||
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
|
||||
|
||||
### 额外塞节点:`--extra-data`
|
||||
|
||||
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
|
||||
|
||||
```bash
|
||||
python upsert.py "<二维码>" --send --extra-data extra.json
|
||||
```
|
||||
|
||||
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
|
||||
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
|
||||
|
||||
> ⚠️ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,**建议一律用文件**。
|
||||
|
||||
### ★ `isNew*` 位串:0 / 1 / 2 的含义
|
||||
|
||||
每个数组节点都配一条 `isNew*` 位串,**一位对应数组里的一条**(顺序必须一致)。
|
||||
|
||||
依据 `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:309-364` 的 `BuildListData()`:
|
||||
|
||||
| 位 | 含义 | 服务端行为 |
|
||||
|---|---|---|
|
||||
| `0` | **存量更新** —— 旧数据里**已有**同主键的条目 | UPDATE |
|
||||
| `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT |
|
||||
| `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 |
|
||||
|
||||
主键见 `api/UpsertUserAll.py` 的 `DIFF_PRIMARY_KEYS`
|
||||
(如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。
|
||||
|
||||
> ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。
|
||||
> 真机抓包样本里 `isNewFavoriteList = "22212"`,可以直观看到 0/1/2 混用的样子。
|
||||
|
||||
> **不确定填什么?** 先看 `data/<你的userId>.json` 里对应的节点:
|
||||
> 主键已存在 → `0`,不存在 → `1`。
|
||||
|
||||
---
|
||||
|
||||
## unlockmusic.py —— 解锁(解禁)歌曲
|
||||
|
||||
```bash
|
||||
python unlockmusic.py "<二维码>" --music 11414 # 预检,不发包
|
||||
python unlockmusic.py "<二维码>" --music 11414 --send # ★ 真实解锁
|
||||
python unlockmusic.py "<二维码>" --music 1085,11414 --send # 多首,逗号分隔
|
||||
python unlockmusic.py "<二维码>" --music 11414 --send --no-unlock-remaster
|
||||
```
|
||||
|
||||
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
|
||||
扫码 → 预览 → 登录 → `UpsertUserAll` → 落库回查 → 登出,一模一样。
|
||||
唯一多出来的东西是「要解锁哪些歌」这个入参。
|
||||
|
||||
**解锁也不是单独的接口** —— 它是往 `userItemList` 里补几条 `UserItem`:
|
||||
|
||||
| itemKind | 解锁什么 |
|
||||
|---|---|
|
||||
| `5` | 歌曲本体(`MusicUnlockList`) |
|
||||
| `6` | Master 难度(`MusicUnlockMasterList`) |
|
||||
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
|
||||
|
||||
条目形如 `{"itemKind":5,"itemId":1085,"stock":1,"isValid":true}`。
|
||||
|
||||
- **标准谱(`musicId < 10000`)的 Master / Re:Master 天生解锁**,只写 `kind5`;
|
||||
只有 DX 谱(`>= 10000`)才需要写 `kind6` / `kind7`。
|
||||
- 服务器上**已存在的条目会自动跳过** —— 重复插入会让服务端直接 HTTP 500。
|
||||
- Re:Master 还受活动开关约束,那是机台侧的事,脚本改不了;写了也可能进游戏仍是灰的。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
|
||||
| `--send` | 真正发包(不加就是预检,零副作用) |
|
||||
| `--no-unlock-master` | 不解禁 Master |
|
||||
| `--no-unlock-remaster` | 不解禁 Re:Master |
|
||||
| `--cooldown N` / `--no-verify` / `--debug` | 和 `upsert.py` 完全一样 |
|
||||
|
||||
> 依据反编译源码 `VOExtensions.cs:981-1008`(四个解锁列表并进 `userItemList`)
|
||||
> 与 `NotesListManager.cs:415/455`(`if (id < 10000) return true;`)。
|
||||
> 这部分**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
|
||||
|
||||
---
|
||||
|
||||
## buy_ticket.py —— 购买功能票
|
||||
|
||||
```bash
|
||||
python buy_ticket.py "<二维码>" --list # 登录后只列当前票券,不购买
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
|
||||
```
|
||||
|
||||
| chargeId | 票 | 上报的 `price` |
|
||||
|---|---|---|
|
||||
| 2 | 2倍チケット | 1 |
|
||||
| 3 | 3倍チケット | 2 |
|
||||
| 5 | 5倍チケット | 4 |
|
||||
|
||||
> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。
|
||||
|
||||
### 它做的安全检查
|
||||
|
||||
1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
|
||||
2. 买票前 **15 秒等待**(贴近真机节奏)
|
||||
3. 跑后再查一次确认状态
|
||||
|
||||
> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
|
||||
> (那是机台本地的上架清单,跟账号状态无关)。
|
||||
>
|
||||
> `UpsertUserChargelog` 内部还有两道硬闸门:
|
||||
> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
|
||||
> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
|
||||
|
||||
---
|
||||
|
||||
## use_ticket.py —— 消耗当前持有的功能票
|
||||
|
||||
```bash
|
||||
python use_ticket.py "<二维码>" --list # 只列当前持有的票
|
||||
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
|
||||
```
|
||||
|
||||
**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**,
|
||||
所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`,
|
||||
服务端就认为这张票被这一局用掉了。
|
||||
|
||||
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
|
||||
> 想再买一张就先用这个把它用掉。
|
||||
|
||||
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md)。
|
||||
|
||||
---
|
||||
|
||||
## reincarnate.py —— 账号转生
|
||||
|
||||
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
|
||||
|
||||
```bash
|
||||
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐)
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
|
||||
```
|
||||
|
||||
**它干了什么**:
|
||||
|
||||
1. 取源账号 A 的 19 个节点数据(联网抓或读 `data/<A>.json`)
|
||||
2. 用目标账号 B 的二维码登录
|
||||
3. 把 A 的 `userMusicDetailList` **分批**上传到 B —— 每包最多 4 首,
|
||||
并自动避开**被服务端禁止的 2 首包**(`[4,4,2]` → `[4,3,3]`)
|
||||
4. 每包之间:**等待 → 上传 → 登出(type=1)→ 续关登录**,循环到传完
|
||||
5. 每包的 playlog 从曲目记录**反推合成**(`achievement` / `deluxscore` / `scoreRank` …)
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
|
||||
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
|
||||
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
|
||||
| `--wait N` | 5.0 | 每次上传前的等待秒数 |
|
||||
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) |
|
||||
|
||||
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
|
||||
|
||||
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md)。
|
||||
|
||||
---
|
||||
|
||||
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
|
||||
|
||||
```bash
|
||||
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
|
||||
python netdelivery.py A63E01ABCDE # 指定 Keychip
|
||||
python netdelivery.py --list # 只列清单 + 直链,不下载
|
||||
python netdelivery.py --all # 全下(不交互)
|
||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||
python netdelivery.py --out D:\SDGB2026\update
|
||||
```
|
||||
|
||||
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
|
||||
下载带**断点续传**与进度/速率显示,默认存到 `download/`(不入库)。
|
||||
|
||||
> 这是**只读**操作,不写任何账号数据。
|
||||
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
|
||||
|
||||
---
|
||||
|
||||
## 主脚本怎么写
|
||||
|
||||
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
|
||||
UpsertUserAll)
|
||||
from config import REGION_ID, PLACE_ID
|
||||
|
||||
def main() -> int:
|
||||
setup_logging("我的脚本") # ← 只需要这一句
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
user_id, token = ParseQRCode(sys.argv[1])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
GetUserPreview(user_id, token) # 顶号保护
|
||||
login = UserLogin(user_id, token,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
# ★ 一个函数跑完整条结算链路
|
||||
UpsertUserAll(user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
dry_run=False, confirm=True)
|
||||
return 0
|
||||
finally:
|
||||
if session["logged_in"]: # ★ 一定要保证登出
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.Logout,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
teardown_logging()
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
三条铁律:
|
||||
|
||||
1. **不要写日志** —— 每个 api 函数会自动打印 `=== 函数名 开始运行 ===` / `[运行结果]`
|
||||
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
|
||||
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
|
||||
|
||||
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md)。
|
||||
|
||||
---
|
||||
|
||||
## 已完成 / 将来
|
||||
|
||||
**已完成(v0)**
|
||||
|
||||
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
|
||||
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
|
||||
- ✅ 购买功能票
|
||||
- ✅ 消耗持有的功能票
|
||||
- ✅ 账号数据快照落盘(`data/<userId>.json`)
|
||||
- ✅ 账号数据迁移(转生)
|
||||
- ✅ 查 / 下 `.app` / `.opt` 更新包
|
||||
- ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
|
||||
- ✅ 零网络自检 284 项
|
||||
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
|
||||
|
||||
**将来可能会加**
|
||||
|
||||
- **网页控制** —— 用浏览器代替命令行操作
|
||||
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
|
||||
- 更完整的真机验证覆盖
|
||||
- `reincarnate.py` 的实跑验证
|
||||
|
||||
---
|
||||
|
||||
## 文档索引
|
||||
|
||||
全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs):
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [项目总览](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 |
|
||||
| [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 |
|
||||
| [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 |
|
||||
| [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 |
|
||||
| [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 |
|
||||
| [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 |
|
||||
| [netdelivery说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 |
|
||||
| [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 |
|
||||
| [购买功能票流程](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 |
|
||||
| [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 |
|
||||
| [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 |
|
||||
| [实跑验证记录](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 |
|
||||
| [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 |
|
||||
| [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 |
|
||||
|
||||
---
|
||||
|
||||
## 特别鸣谢
|
||||
|
||||
- **志成🍥** —— 提供脱壳 DLL
|
||||
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
|
||||
- **DeepSeek V4.1 Flash** —— 主要代码编写
|
||||
- **GPT-Image-2** —— Logo 设计
|
||||
- [**NVIDIA NIM**](https://build.nvidia.com/models)、
|
||||
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
|
||||
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型
|
||||
- [**eaquira**](https://git.fragrance.moe/Fragrance/eaquira)—— 思路启发
|
||||
- **神秘群U** —— 私有科技的 Web 启发(暂无)
|
||||
- **香水所有群友** —— 和谐帮助
|
||||
|
||||
*(还有什么以后补充)*
|
||||
|
||||
---
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。
|
||||
请遵守游戏服务条款,不要用于破坏游戏公平性。
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
|
||||
================================================================================
|
||||
|
||||
★ 这个模块解决什么问题:
|
||||
|
||||
````
|
||||
错误写法(危险):
|
||||
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
||||
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
|
||||
# 服务端看到的就是「重复购买」,有封号风险。
|
||||
```
|
||||
|
||||
正确写法:
|
||||
```
|
||||
held = GetUserCharge(user_id) # ① 先查:有没有持票
|
||||
...
|
||||
UpsertUserChargelog(user_id, 2, ...) # ② 再买
|
||||
```
|
||||
|
||||
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
|
||||
没查过就抛 `ChargelogGuardError`(**未发包**)。
|
||||
|
||||
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
|
||||
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
|
||||
脚本连着买两张是明显的异常行为,这里直接拦掉。
|
||||
|
||||
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
|
||||
免得下次运行时被上一次的记录误拦。
|
||||
|
||||
依据:
|
||||
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
|
||||
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
|
||||
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
|
||||
CHARGE_READ_FRESH_SEC = 600.0
|
||||
|
||||
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
|
||||
_LAST_READ: Dict[int, float] = {}
|
||||
|
||||
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
|
||||
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
|
||||
|
||||
#: user_id -> 本进程内已经买过的 chargeId 列表
|
||||
_PURCHASES: Dict[int, List[int]] = {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、GetUserCharge 侧:记一笔
|
||||
# =============================================================================
|
||||
|
||||
def note_user_charge_read(
|
||||
user_id: int,
|
||||
charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
记录「刚刚为这个 user_id 查过票券」。
|
||||
|
||||
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
|
||||
"""
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
_LAST_READ[uid] = time.monotonic()
|
||||
if isinstance(charge_list, list):
|
||||
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
|
||||
|
||||
|
||||
def last_read_age(user_id: int) -> Optional[float]:
|
||||
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
ts = _LAST_READ.get(uid)
|
||||
if ts is None:
|
||||
return None
|
||||
return max(0.0, time.monotonic() - ts)
|
||||
|
||||
|
||||
def has_fresh_read(
|
||||
user_id: int,
|
||||
max_age: float = CHARGE_READ_FRESH_SEC,
|
||||
) -> bool:
|
||||
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
|
||||
age = last_read_age(user_id)
|
||||
if age is None:
|
||||
return False
|
||||
return age <= float(max_age)
|
||||
|
||||
|
||||
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
|
||||
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
|
||||
try:
|
||||
return list(_LAST_LIST.get(int(user_id)) or [])
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def held_charges(user_id: int) -> List[Dict[str, Any]]:
|
||||
"""最近一次查到的「持有中」(stock > 0)的票券。"""
|
||||
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、UpsertUserChargelog 侧:买之前查
|
||||
# =============================================================================
|
||||
|
||||
def note_charge_purchase(user_id: int, charge_id: int) -> None:
|
||||
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
_PURCHASES.setdefault(uid, []).append(int(charge_id))
|
||||
|
||||
|
||||
def purchased_charge_ids(user_id: int) -> List[int]:
|
||||
"""本进程内这个 user_id 已经买过的 chargeId。"""
|
||||
try:
|
||||
return list(_PURCHASES.get(int(user_id)) or [])
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def reset(user_id: Optional[int] = None) -> None:
|
||||
"""清空记忆(测试用;不传 user_id 就全清)。"""
|
||||
if user_id is None:
|
||||
_LAST_READ.clear()
|
||||
_LAST_LIST.clear()
|
||||
_PURCHASES.clear()
|
||||
return
|
||||
try:
|
||||
uid = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
_LAST_READ.pop(uid, None)
|
||||
_LAST_LIST.pop(uid, None)
|
||||
_PURCHASES.pop(uid, None)
|
||||
@@ -0,0 +1,311 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Chime.py —— 扫码登录(二维码 -> userId + token)
|
||||
================================================================================
|
||||
|
||||
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
|
||||
|
||||
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
协议依据
|
||||
--------------------------------------------------------------------------------
|
||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
|
||||
strQRData, strTitleKey, serverURLIndex)
|
||||
strGameID = "MAID"
|
||||
strChipID = KeychipID
|
||||
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
|
||||
strTitleKey= "SDGB"
|
||||
|
||||
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
|
||||
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
|
||||
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
|
||||
EraseHashIdentifier() : [20:] 64 位载荷
|
||||
|
||||
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
|
||||
KeychipID = AMDaemon.System.KeychipId.Value
|
||||
TitleKey = AMDaemon.System.GameId
|
||||
GameID = "MAID"
|
||||
|
||||
docs/舞萌DX_扫码登录协议技术规范.md
|
||||
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
|
||||
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
|
||||
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import ParseQRCode
|
||||
|
||||
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
|
||||
# 也可以直接传二维码 URL
|
||||
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from config import KEYCHIP_ID
|
||||
|
||||
# =============================================================================
|
||||
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs 分支《舞萌DX_扫码登录协议技术规范》
|
||||
|
||||
#: aimedb 服务器
|
||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
||||
AIMEDB_PORT = 443
|
||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
||||
|
||||
#: 签名密钥(含前导 X,32 字符)
|
||||
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
#: 固定字段
|
||||
QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 二维码格式常量(CCommQRCode.cs + 技术规范)
|
||||
# =============================================================================
|
||||
|
||||
QR_PREFIX = "SGWC" # Chime.WechatID
|
||||
QR_GAME_ID = "MAID"
|
||||
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
|
||||
QR_PAYLOAD_LEN = 64
|
||||
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
|
||||
|
||||
#: 默认机台 chipID
|
||||
#:
|
||||
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
|
||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
||||
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
|
||||
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
|
||||
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
|
||||
#: 而游戏服务器那边用的是短值:
|
||||
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
||||
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
|
||||
#:
|
||||
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
|
||||
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
|
||||
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
|
||||
DEFAULT_CHIP_ID = KEYCHIP_ID
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 内部工具
|
||||
# =============================================================================
|
||||
|
||||
def _extract_qr_from_url(url: str) -> str:
|
||||
"""
|
||||
从二维码 URL 提取完整 84 字符串。
|
||||
|
||||
URL 形如:
|
||||
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
|
||||
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
|
||||
"""
|
||||
path = urlparse(url).path
|
||||
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
|
||||
if not match:
|
||||
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
|
||||
return QR_PREFIX + match.group(1)
|
||||
|
||||
|
||||
def _normalize_qr(qr_input: str) -> str:
|
||||
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
|
||||
qr = (qr_input or "").strip()
|
||||
|
||||
if qr.startswith("http://") or qr.startswith("https://"):
|
||||
log_debug("输入是 URL,先提取二维码字符串")
|
||||
qr = _extract_qr_from_url(qr)
|
||||
|
||||
if len(qr) != QR_TOTAL_LEN:
|
||||
raise ValueError(
|
||||
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
|
||||
)
|
||||
if not qr.startswith(QR_PREFIX):
|
||||
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
|
||||
if qr[4:8] != QR_GAME_ID:
|
||||
raise ValueError(
|
||||
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
|
||||
)
|
||||
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
|
||||
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
|
||||
|
||||
log_debug(
|
||||
"二维码校验通过:timestamp=" + qr[8:20]
|
||||
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
|
||||
)
|
||||
return qr
|
||||
|
||||
|
||||
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
|
||||
"""
|
||||
构造请求体。
|
||||
|
||||
签名(技术规范 §4.1):
|
||||
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
|
||||
★ 拼接里不含 qrCode、不含 titlekey
|
||||
|
||||
qrCode 字段(CCommQRCode.EraseHashIdentifier):
|
||||
qr_string[20:] 即 64 位载荷
|
||||
|
||||
返回:
|
||||
(body_bytes, timestamp, key)
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
|
||||
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
|
||||
|
||||
body_dict = {
|
||||
"chipID": chip_id,
|
||||
"openGameID": QR_OPEN_GAME_ID,
|
||||
"key": key,
|
||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
||||
"timestamp": timestamp,
|
||||
"titlekey": QR_TITLE_KEY,
|
||||
}
|
||||
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
|
||||
return body_bytes, timestamp, key
|
||||
|
||||
|
||||
def _send(body_bytes: bytes) -> dict:
|
||||
"""
|
||||
发送 HTTPS POST 并解析 JSON 响应。
|
||||
|
||||
★ 请求头只放 Connection / User-Agent / Content-Length。
|
||||
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
headers = {
|
||||
"Connection": "Keep-Alive",
|
||||
"User-Agent": QR_USER_AGENT,
|
||||
"Content-Length": str(len(body_bytes)),
|
||||
# ★ 此处刻意不加 Content-Type
|
||||
}
|
||||
|
||||
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
|
||||
try:
|
||||
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
text = resp.read().decode("utf-8")
|
||||
status = resp.status
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log_response(status=status, body=text, api_name="aimedb/get_data")
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception as exc:
|
||||
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
|
||||
|
||||
|
||||
def _parse(resp: dict) -> Tuple[int, str]:
|
||||
"""解析响应,返回 (user_id, token)。"""
|
||||
error_id = resp.get("errorID", -1)
|
||||
user_id = resp.get("userID", -1)
|
||||
token = resp.get("token", "") or ""
|
||||
|
||||
if error_id != 0:
|
||||
raise RuntimeError(
|
||||
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
|
||||
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
|
||||
)
|
||||
if user_id == -1 or not token:
|
||||
raise RuntimeError(
|
||||
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
|
||||
)
|
||||
return int(user_id), str(token)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 公开 API
|
||||
# =============================================================================
|
||||
|
||||
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
|
||||
"""
|
||||
解析二维码并换取 userId 与 token。
|
||||
|
||||
参数:
|
||||
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
|
||||
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
|
||||
|
||||
返回:
|
||||
(user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
|
||||
|
||||
异常:
|
||||
ValueError: 二维码格式不合法
|
||||
RuntimeError: 服务器返回错误或网络异常
|
||||
"""
|
||||
log_info("解析二维码登录:chip_id=" + chip_id)
|
||||
|
||||
qr_string = _normalize_qr(qr_input)
|
||||
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
|
||||
|
||||
log_request(
|
||||
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
|
||||
body={
|
||||
"chipID": chip_id,
|
||||
"openGameID": QR_OPEN_GAME_ID,
|
||||
"key": key,
|
||||
"qrCode": qr_string[QR_PREFIX_LEN:],
|
||||
"timestamp": timestamp,
|
||||
"titlekey": QR_TITLE_KEY,
|
||||
},
|
||||
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
|
||||
"Content-Length": str(len(body_bytes))},
|
||||
api_name="aimedb/get_data",
|
||||
)
|
||||
|
||||
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
|
||||
resp = _send(body_bytes)
|
||||
user_id, token = _parse(resp)
|
||||
|
||||
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
|
||||
+ str(len(token)) + ")")
|
||||
return user_id, token
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chime_test")
|
||||
try:
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
|
||||
return 1
|
||||
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
|
||||
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
|
||||
print("")
|
||||
print("user_id =", user_id)
|
||||
print("token =", token)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("扫码登录失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的计费档位。
|
||||
|
||||
对应接口:GetGameChargeApi
|
||||
请求体 :{"isAll": false}(GameChargeRequestVO)
|
||||
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
|
||||
source/2026-10-05.log:9 真机请求 {"isAll":false}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameCharge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameChargeRequestVO:
|
||||
"""请求 VO(对应 GameChargeRequestVO)。"""
|
||||
isAll: bool = False
|
||||
|
||||
|
||||
def GetGameCharge(
|
||||
*,
|
||||
is_all: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏计费配置(对应 GetGameChargeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameChargeList
|
||||
"""
|
||||
log_info("取游戏计费配置:isAll=" + str(is_all))
|
||||
return request_dict(
|
||||
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,175 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
================================================================================
|
||||
|
||||
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
|
||||
|
||||
对应接口:GetGameSettingApi
|
||||
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
|
||||
|
||||
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
|
||||
|
||||
调用示例:
|
||||
from api import GetGameSettings
|
||||
resp = GetGameSettings()
|
||||
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class GameSetting:
|
||||
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
|
||||
isMaintenance: bool = False
|
||||
requestInterval: int = 0
|
||||
rebootStartTime: str = ""
|
||||
rebootEndTime: str = ""
|
||||
rebootInterval: int = 0
|
||||
movieUploadLimit: int = 0
|
||||
movieStatus: int = 0
|
||||
movieServerUri: str = ""
|
||||
deliverServerUri: str = ""
|
||||
oldServerUri: str = ""
|
||||
usbDlServerUri: str = ""
|
||||
maxCountRivalMusic: int = 0
|
||||
replicationDelayLimit: int = 0
|
||||
exclusionStartTime: str = ""
|
||||
exclusionEndTime: str = ""
|
||||
pingDisable: bool = False
|
||||
packetTimeout: int = 0
|
||||
packetTimeoutLong: int = 0
|
||||
packetRetryCount: int = 0
|
||||
userDataDlErrTimeout: int = 0
|
||||
userDataDlErrRetryCount: int = 0
|
||||
userDataDlErrSamePacketRetryCount: int = 0
|
||||
userDataUpSkipTimeout: int = 0
|
||||
userDataUpSkipRetryCount: int = 0
|
||||
iconPhotoDisable: bool = False
|
||||
uploadPhotoDisable: bool = False
|
||||
photoDisableArea: Optional[str] = None
|
||||
collectDelayInfoDisable: bool = False
|
||||
maxCountMusic: int = 0
|
||||
maxCountItem: int = 0
|
||||
nameEntryDisable: bool = False
|
||||
packetRecreateCount: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSettingRequestVO:
|
||||
"""请求 VO(对应 GameSettingRequestVO)。"""
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameSettingResponseVO:
|
||||
"""响应 VO(对应 GameSettingResponseVO)。"""
|
||||
isAouAccession: bool = False
|
||||
gameSetting: GameSetting = field(default_factory=GameSetting)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 公开 API
|
||||
# =============================================================================
|
||||
|
||||
def GetGameSettings(
|
||||
*,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> GameSettingResponseVO:
|
||||
"""
|
||||
获取游戏设置(服务器下发的运行时配置)。
|
||||
|
||||
参数:
|
||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
||||
client_id: 机台短值(可选,默认 config.CLIENT_ID)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
GameSettingResponseVO
|
||||
"""
|
||||
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
|
||||
|
||||
raw = request_dict(
|
||||
"GetGameSettingApi",
|
||||
GameSettingRequestVO(placeId=place_id, clientId=client_id),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
gs_raw = raw.get("gameSetting") or {}
|
||||
known = {f for f in GameSetting.__dataclass_fields__}
|
||||
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
|
||||
resp = GameSettingResponseVO(
|
||||
isAouAccession=bool(raw.get("isAouAccession", False)),
|
||||
gameSetting=gs,
|
||||
)
|
||||
|
||||
if gs.isMaintenance:
|
||||
log_warning("服务器处于维护状态(isMaintenance=True)")
|
||||
if gs.pingDisable:
|
||||
log_info("服务器已禁用心跳(pingDisable=True)")
|
||||
|
||||
log_info(
|
||||
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
|
||||
+ " requestInterval=" + str(gs.requestInterval)
|
||||
+ " packetTimeout=" + str(gs.packetTimeout)
|
||||
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
|
||||
+ " pingDisable=" + str(gs.pingDisable)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
def is_maintenance(settings: GameSettingResponseVO) -> bool:
|
||||
"""便捷判断:服务器是否处于维护状态。"""
|
||||
return bool(settings.gameSetting.isMaintenance)
|
||||
|
||||
|
||||
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
|
||||
"""便捷判断:是否已禁用心跳。"""
|
||||
return bool(settings.gameSetting.pingDisable)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("gamesettings_test")
|
||||
try:
|
||||
resp = GetGameSettings()
|
||||
gs = resp.gameSetting
|
||||
print("")
|
||||
for name in GameSetting.__dataclass_fields__:
|
||||
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
from .LogSetup import log_error
|
||||
log_error("获取游戏设置失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
|
||||
================================================================================
|
||||
|
||||
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
|
||||
|
||||
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
为什么需要快照
|
||||
--------------------------------------------------------------------------------
|
||||
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
|
||||
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
|
||||
再把本局的影响叠加到基线上 —— 这就是快照的作用。
|
||||
|
||||
真机对照(source/2026-10-05.log):
|
||||
|
||||
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|
||||
|---------------------|-------------------|----------------------|
|
||||
| userData.playCount | 1257 | 1258 (+1) |
|
||||
| userMapList | 104 条 | 仅 1 条(distance 变)|
|
||||
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
|
||||
| userItemList | 1070 条 | [](无变化) |
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import GetNeedUserData
|
||||
|
||||
snapshot = GetNeedUserData(user_id, token=token)
|
||||
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_debug, log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
|
||||
# 各取数脚本(一个接口一个脚本)
|
||||
from .GetUserData import GetUserData
|
||||
from .GetUserExtend import GetUserExtend
|
||||
from .GetUserOption import GetUserOption
|
||||
from .GetUserCharacter import GetUserCharacter
|
||||
from .GetUserGhost import GetUserGhost
|
||||
from .GetUserMap import GetUserMapAll
|
||||
from .GetUserLoginBonus import GetUserLoginBonusAll
|
||||
from .GetUserRating import GetUserRating
|
||||
from .GetUserItem import GetUserItemAll
|
||||
from .GetUserMusic import GetUserMusicDetailAll
|
||||
from .GetUserCourse import GetUserCourseAll
|
||||
from .GetUserCharge import GetUserCharge
|
||||
from .GetUserFavorite import GetUserFavoriteAll
|
||||
from .GetUserActivity import GetUserActivity
|
||||
from .GetUserMission import GetUserMissionData
|
||||
from .GetUserIntimate import GetUserIntimate
|
||||
from .GetUserShopStock import GetUserShopStock
|
||||
from .GetUserFavoriteItem import GetUserFavoriteItemAll
|
||||
from .GetUserKaleidxScope import GetUserKaleidxScope
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 落盘目录:<工程根>/data/<userId>.json
|
||||
# =============================================================================
|
||||
|
||||
#: 工程根目录(本文件在 <root>/api/ 下)
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: 用户数据目录:<工程根>/data
|
||||
DATA_DIR: Path = PROJECT_ROOT / "data"
|
||||
|
||||
|
||||
def user_data_path(user_id: int) -> Path:
|
||||
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
|
||||
return DATA_DIR / (str(int(user_id)) + ".json")
|
||||
|
||||
|
||||
def save_user_data(snapshot: Dict[str, Any], user_id: int,
|
||||
path: Optional[str] = None) -> str:
|
||||
"""
|
||||
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
|
||||
|
||||
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
|
||||
★ 目录不存在会自动创建。
|
||||
|
||||
参数:
|
||||
snapshot: GetNeedUserData 的返回值
|
||||
user_id: 用户 ID(决定文件名)
|
||||
path: 自定义路径(可选,默认走 data/<userId>.json)
|
||||
|
||||
返回:实际写入的文件路径
|
||||
"""
|
||||
target = Path(path) if path else user_data_path(user_id)
|
||||
try:
|
||||
os.makedirs(str(target.parent), exist_ok=True)
|
||||
with open(str(target), "w", encoding="utf-8") as fp:
|
||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception as exc:
|
||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
||||
return str(target)
|
||||
size = 0
|
||||
try:
|
||||
size = os.path.getsize(str(target))
|
||||
except Exception:
|
||||
pass
|
||||
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
|
||||
return str(target)
|
||||
|
||||
|
||||
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
读回 <工程根>/data/<userId>.json。
|
||||
|
||||
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
|
||||
"""
|
||||
target = Path(path) if path else user_data_path(user_id)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("用户数据文件不存在:" + str(target))
|
||||
with open(str(target), "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
log_info("用户数据已加载:" + str(target))
|
||||
return data
|
||||
|
||||
|
||||
def list_saved_user_ids() -> List[int]:
|
||||
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
|
||||
if not DATA_DIR.exists():
|
||||
return []
|
||||
out: List[int] = []
|
||||
for name in os.listdir(str(DATA_DIR)):
|
||||
if not name.endswith(".json"):
|
||||
continue
|
||||
try:
|
||||
out.append(int(name[:-5]))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(out)
|
||||
|
||||
|
||||
#: 默认要抓的节点(顺序即抓取顺序)
|
||||
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
|
||||
DEFAULT_SNAPSHOT_NODES: tuple = (
|
||||
"userData",
|
||||
"userExtend",
|
||||
"userOption",
|
||||
"userCharacterList",
|
||||
"userMapList",
|
||||
"userLoginBonusList",
|
||||
"userRatingList",
|
||||
"userItemList",
|
||||
"userMusicDetailList",
|
||||
"userCourseList",
|
||||
"userChargeList",
|
||||
"userFavoriteList",
|
||||
"userActivityList",
|
||||
"userMissionDataList",
|
||||
"userIntimateList",
|
||||
"userShopItemStockList",
|
||||
"userFavoritemusicList",
|
||||
"userKaleidxScopeList",
|
||||
"userGhost",
|
||||
)
|
||||
|
||||
|
||||
def GetNeedUserData(
|
||||
user_id: int,
|
||||
*,
|
||||
token: str = "",
|
||||
client_id: str = CLIENT_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
region_id: int = REGION_ID,
|
||||
shop_item_ids: Optional[Sequence[int]] = None,
|
||||
nodes: Optional[Sequence[str]] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
strict: bool = False,
|
||||
save: bool = True,
|
||||
save_path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
抓取结算所需的全部基线数据。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(可选,多数取数接口不需要)
|
||||
client_id: 机台短值
|
||||
place_id: 店铺 ID
|
||||
region_id: 区域 ID
|
||||
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
|
||||
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
|
||||
timeout_ms: 超时毫秒
|
||||
client: 可复用的 MaimaiNetClient
|
||||
strict: True = 任一接口失败就抛异常;
|
||||
False(默认)= 记录失败并跳过,保证链路不崩
|
||||
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
|
||||
(★ 新数据总是覆盖老数据)
|
||||
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
|
||||
|
||||
返回:
|
||||
{
|
||||
"userData": <GetUserDataApi 原始响应>,
|
||||
"userExtend": <GetUserExtendApi 原始响应>,
|
||||
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
|
||||
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
|
||||
...
|
||||
"_failed": [ {"node":.., "error":..}, ... ]
|
||||
}
|
||||
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
|
||||
"""
|
||||
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
|
||||
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
|
||||
|
||||
raw: Dict[str, Any] = {}
|
||||
failed: List[Dict[str, str]] = []
|
||||
|
||||
def _call(node: str, fn, *args, **kwargs):
|
||||
"""逐节点容错:单个接口失败不影响整体。"""
|
||||
try:
|
||||
raw[node] = fn(*args, **kwargs)
|
||||
return True
|
||||
except Exception as exc:
|
||||
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
|
||||
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
|
||||
if strict:
|
||||
raise
|
||||
return False
|
||||
|
||||
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
|
||||
if "userData" in wanted:
|
||||
_call("userData", GetUserData, user_id, **common)
|
||||
|
||||
if "userExtend" in wanted:
|
||||
_call("userExtend", GetUserExtend, user_id, **common)
|
||||
|
||||
if "userOption" in wanted:
|
||||
_call("userOption", GetUserOption, user_id, **common)
|
||||
|
||||
if "userCharacterList" in wanted:
|
||||
_call("userCharacterList", GetUserCharacter, user_id, **common)
|
||||
|
||||
if "userMapList" in wanted:
|
||||
_call("userMapList", GetUserMapAll, user_id, **common)
|
||||
|
||||
if "userLoginBonusList" in wanted:
|
||||
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
|
||||
|
||||
if "userRatingList" in wanted:
|
||||
_call("userRatingList", GetUserRating, user_id, **common)
|
||||
|
||||
if "userItemList" in wanted:
|
||||
_call("userItemList", GetUserItemAll, user_id, **common)
|
||||
|
||||
if "userMusicDetailList" in wanted:
|
||||
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
|
||||
|
||||
if "userCourseList" in wanted:
|
||||
_call("userCourseList", GetUserCourseAll, user_id, **common)
|
||||
|
||||
if "userChargeList" in wanted:
|
||||
_call("userChargeList", GetUserCharge, user_id, **common)
|
||||
|
||||
if "userFavoriteList" in wanted:
|
||||
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
|
||||
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
|
||||
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
|
||||
if _call("userFavoriteList", GetUserFavoriteAll, user_id, **common):
|
||||
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
|
||||
if fav_raw:
|
||||
raw["_raw_favorite"] = fav_raw
|
||||
|
||||
if "userActivityList" in wanted:
|
||||
_call("userActivityList", GetUserActivity, user_id, **common)
|
||||
|
||||
if "userMissionDataList" in wanted:
|
||||
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
|
||||
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
|
||||
_call("userMissionDataList", GetUserMissionData, user_id, **common)
|
||||
|
||||
if "userIntimateList" in wanted:
|
||||
_call("userIntimateList", GetUserIntimate, user_id, **common)
|
||||
|
||||
if "userShopItemStockList" in wanted:
|
||||
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
|
||||
if ids:
|
||||
_call("userShopItemStockList", GetUserShopStock, user_id,
|
||||
shop_item_id_list=ids, **common)
|
||||
else:
|
||||
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
|
||||
|
||||
if "userFavoritemusicList" in wanted:
|
||||
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
|
||||
|
||||
if "userKaleidxScopeList" in wanted:
|
||||
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
|
||||
|
||||
if "userGhost" in wanted:
|
||||
_call("userGhost", GetUserGhost, user_id, **common)
|
||||
|
||||
raw["_failed"] = failed
|
||||
raw["_userId"] = int(user_id)
|
||||
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
|
||||
+ str(len(failed)) + " 个")
|
||||
|
||||
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
|
||||
if save or save_path:
|
||||
try:
|
||||
save_user_data(raw, user_id, path=save_path)
|
||||
except Exception as exc:
|
||||
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
|
||||
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
|
||||
with open(path, "w", encoding="utf-8") as fp:
|
||||
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
|
||||
log_info("快照已保存:" + path)
|
||||
return path
|
||||
|
||||
|
||||
def load_snapshot(path: str) -> Dict[str, Any]:
|
||||
"""从 JSON 文件读回快照。"""
|
||||
with open(path, "r", encoding="utf-8") as fp:
|
||||
data = json.load(fp)
|
||||
log_info("快照已加载:" + path)
|
||||
return data
|
||||
|
||||
|
||||
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
|
||||
"""
|
||||
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
|
||||
|
||||
条数含义:
|
||||
userData 这类单元素数组 -> 1
|
||||
userMusicDetailList 这类列表 -> 摊平后的条数
|
||||
"""
|
||||
out: Dict[str, int] = {}
|
||||
for node, value in (snapshot or {}).items():
|
||||
if node.startswith("_"):
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
out[node] = len(value)
|
||||
elif isinstance(value, dict):
|
||||
# 从响应里挑出列表
|
||||
found = False
|
||||
for key, sub in value.items():
|
||||
if isinstance(sub, list):
|
||||
out[node] = len(sub)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
out[node] = 1
|
||||
else:
|
||||
out[node] = 0
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .LogSetup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("get_need_user_data_test")
|
||||
try:
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
|
||||
return 1
|
||||
|
||||
user_id = int(sys.argv[1])
|
||||
token = ""
|
||||
save_path = ""
|
||||
args = sys.argv[2:]
|
||||
i = 0
|
||||
while i < len(args):
|
||||
if args[i] == "--token" and i + 1 < len(args):
|
||||
token = args[i + 1]
|
||||
i += 2
|
||||
elif args[i] == "--save" and i + 1 < len(args):
|
||||
save_path = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
snap = GetNeedUserData(user_id, token=token)
|
||||
summary = summarize_snapshot(snap)
|
||||
print("")
|
||||
for node, count in summary.items():
|
||||
print(" " + node.ljust(28) + "= " + str(count) + " 条")
|
||||
if save_path:
|
||||
save_snapshot(snap, save_path)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("抓取快照失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
|
||||
|
||||
对应接口:GetUserActivityApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userActivity:{playList:[...], musicList:[...]}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
|
||||
|
||||
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
|
||||
Get 侧 :单个对象 userActivity = {playList, musicList}
|
||||
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
|
||||
不是把两个数组首尾相接摊平。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserActivity
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserActivity(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户活动记录(对应 GetUserActivityApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userActivity 子对象
|
||||
"""
|
||||
log_info("取用户活动记录:user_id=" + str(user_id))
|
||||
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
|
||||
================================================================================
|
||||
|
||||
取玩家所有角色的等级与觉醒度(真机 312 条)。
|
||||
|
||||
对应接口:GetUserCharacterApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userCharacterList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
|
||||
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserCharacter
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserCharacter(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户角色列表(对应 GetUserCharacterApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userCharacterList
|
||||
"""
|
||||
log_info("取用户角色列表:user_id=" + str(user_id))
|
||||
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的票券持有情况(真机 3 条)。
|
||||
|
||||
对应接口:GetUserChargeApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
|
||||
|
||||
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
|
||||
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserCharge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .ChargeGuard import note_user_charge_read
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserCharge(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户票券(对应 GetUserChargeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userChargeList
|
||||
"""
|
||||
log_info("取用户票券:user_id=" + str(user_id))
|
||||
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
# ★ 记一笔「这个账号刚查过票券」——
|
||||
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
|
||||
# 见 api/ChargeGuard.py 的说明。
|
||||
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||
note_user_charge_read(user_id, lst)
|
||||
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||
if held:
|
||||
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
|
||||
+ "、".join("chargeId=" + str(c.get("chargeId"))
|
||||
+ " stock=" + str(c.get("stock")) for c in held))
|
||||
else:
|
||||
log_info("该账号当前没有持有功能票")
|
||||
return raw
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
|
||||
|
||||
对应接口:GetUserCourseApi
|
||||
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
|
||||
响应 :{userId, length, nextIndex, userCourseList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
|
||||
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
|
||||
|
||||
调用示例:
|
||||
from api import GetUserCourse
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserCourse(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userCourseList
|
||||
"""
|
||||
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserCourseApi",
|
||||
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserCourseAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户课题成绩(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userCourseList": [...]}
|
||||
"""
|
||||
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
|
||||
items = get_all_pages(
|
||||
"GetUserCourseApi", user_id, "userCourseList",
|
||||
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
|
||||
return {"userCourseList": items}
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserData.py —— 取用户全量数据(GetUserDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
|
||||
|
||||
对应接口:GetUserDataApi
|
||||
请求体 :UserIdRequestVO(只有 userId)
|
||||
响应 :{userId, userData:{86 键}, banState}
|
||||
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
|
||||
|
||||
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
|
||||
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
|
||||
|
||||
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
|
||||
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
|
||||
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
|
||||
记录造成的假象(两条响应被合并显示)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserData(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全量数据(对应 GetUserDataApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
client_id: 机台短值(可选)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
|
||||
"""
|
||||
log_info("取用户全量数据:user_id=" + str(user_id))
|
||||
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
|
||||
================================================================================
|
||||
|
||||
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
|
||||
|
||||
对应接口:GetUserExtendApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userExtend:{...}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
|
||||
|
||||
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
|
||||
(真机两处都是 2022),组包时要注意。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserExtend
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserExtend(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户扩展设置(对应 GetUserExtendApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userExtend 子对象
|
||||
"""
|
||||
log_info("取用户扩展设置:user_id=" + str(user_id))
|
||||
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
|
||||
|
||||
对应接口:GetUserFavoriteApi
|
||||
请求体 :UserFavoriteRequestVO(userId + itemKind)
|
||||
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
|
||||
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
|
||||
|
||||
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
|
||||
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserFavorite, GetUserFavoriteAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
|
||||
|
||||
|
||||
def GetUserFavorite(
|
||||
user_id: int,
|
||||
item_kind: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户某一类收藏(对应 GetUserFavoriteApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userFavorite
|
||||
"""
|
||||
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
|
||||
return _get(
|
||||
"GetUserFavoriteApi",
|
||||
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserFavoriteAll(
|
||||
user_id: int,
|
||||
*,
|
||||
item_kinds: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全部收藏(按真机顺序请求 5 个类别)。
|
||||
|
||||
参数:
|
||||
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
|
||||
|
||||
返回:
|
||||
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
|
||||
"""
|
||||
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
|
||||
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
raw_items: List[Dict[str, Any]] = []
|
||||
for kind in kinds:
|
||||
resp = _get(
|
||||
"GetUserFavoriteApi",
|
||||
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
fav = resp.get("userFavorite") or {}
|
||||
if not fav:
|
||||
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
|
||||
fav = {"itemKind": kind, "itemIdList": []}
|
||||
|
||||
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
|
||||
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
|
||||
raw_items.append(dict(fav))
|
||||
|
||||
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
|
||||
filled = dict(fav)
|
||||
if not filled.get("itemKind"):
|
||||
filled["itemKind"] = kind
|
||||
result.append(filled)
|
||||
|
||||
log_info("收藏获取完成:" + str(len(result)) + " 类")
|
||||
return {"userFavoriteList": result, "_raw": raw_items}
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
|
||||
|
||||
对应接口:GetUserFavoriteItemApi
|
||||
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
|
||||
+ isAllFavoriteItem)
|
||||
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
|
||||
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
|
||||
|
||||
★ 字段名是 kind(不是 itemKind),别写错。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserFavoriteItem, GetUserFavoriteItemAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
|
||||
|
||||
#: kind 取值
|
||||
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
|
||||
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
|
||||
|
||||
|
||||
def GetUserFavoriteItem(
|
||||
user_id: int,
|
||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
is_all_favorite_item: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
kind: 1=收藏乐曲 2=收藏对手(默认 1)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userFavoriteItemList
|
||||
"""
|
||||
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
|
||||
+ " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserFavoriteItemApi",
|
||||
UserFavoriteItemRequestVO(
|
||||
userId=user_id, kind=kind, nextIndex=next_index,
|
||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
|
||||
),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserFavoriteItemAll(
|
||||
user_id: int,
|
||||
kind: int = FAVORITE_ITEM_KIND_MUSIC,
|
||||
*,
|
||||
is_all_favorite_item: bool = False,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户收藏项(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userFavoriteItemList": [...]}
|
||||
"""
|
||||
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
|
||||
items = get_all_pages(
|
||||
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
|
||||
body_factory=lambda ni: UserFavoriteItemRequestVO(
|
||||
userId=user_id, kind=kind, nextIndex=ni or 0,
|
||||
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("收藏项获取完成:" + str(len(items)) + " 条")
|
||||
return {"userFavoriteItemList": items}
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家本次可领取的好友里程(getMiles)。
|
||||
|
||||
对应接口:GetUserFriendBonusApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{returnCode, userId, getMiles}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
|
||||
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
|
||||
|
||||
调用示例:
|
||||
from api import GetUserFriendBonus, extract_gift_points
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserFriendBonus(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户好友里程(对应 GetUserFriendBonusApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
|
||||
"""
|
||||
log_info("取用户好友里程:user_id=" + str(user_id))
|
||||
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
if int(resp.get("returnCode", 0) or 0) != 1:
|
||||
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
|
||||
return resp
|
||||
|
||||
|
||||
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
|
||||
"""
|
||||
从好友里程响应里取出 getMiles(本次可领取的里程数)。
|
||||
|
||||
参数:
|
||||
friend_bonus: GetUserFriendBonus 的返回值
|
||||
|
||||
返回:
|
||||
getMiles 整数(取不到则为 0)
|
||||
"""
|
||||
try:
|
||||
return int((friend_bonus or {}).get("getMiles", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的「幽灵」对战记录(真机 15 条)。
|
||||
|
||||
对应接口:GetUserGhostApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{length, userGhostList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
|
||||
|
||||
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
|
||||
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
|
||||
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserGhost
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserGhost(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户幽灵数据(对应 GetUserGhostApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userGhostList
|
||||
"""
|
||||
log_info("取用户幽灵数据:user_id=" + str(user_id))
|
||||
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
|
||||
================================================================================
|
||||
|
||||
取玩家与各搭档的亲密度(真机 2 条)。
|
||||
|
||||
对应接口:GetUserIntimateApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
|
||||
|
||||
调用示例:
|
||||
from api import GetUserIntimate
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserIntimate(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户搭档亲密度(对应 GetUserIntimateApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userIntimateList
|
||||
"""
|
||||
log_info("取用户搭档亲密度:user_id=" + str(user_id))
|
||||
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserItem.py —— 取用户道具(GetUserItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
|
||||
|
||||
对应接口:GetUserItemApi
|
||||
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
|
||||
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
|
||||
--------------------------------------------------------------------------------
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
||||
|
||||
public PacketGetUserItem(ulong userId, long itemId, ...)
|
||||
{
|
||||
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
|
||||
}
|
||||
// Proc(): nextIndex != 0 时继续翻页
|
||||
|
||||
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
|
||||
传 0 会被服务端拒绝(HTTP 500)。
|
||||
|
||||
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
|
||||
第 1 页 nextIndex = kind * 1e10
|
||||
后续翻页沿用响应回的 nextIndex
|
||||
|
||||
调用示例:
|
||||
from api import GetUserItem, GetUserItemAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_debug, log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
|
||||
|
||||
|
||||
def GetUserItem(
|
||||
user_id: int,
|
||||
item_kind: int,
|
||||
*,
|
||||
next_index: Optional[int] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户某一类道具(对应 GetUserItemApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
|
||||
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
|
||||
item_kind * 10000000000 的哨兵值。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userItemList
|
||||
"""
|
||||
if next_index is None:
|
||||
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
|
||||
|
||||
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
|
||||
+ " nextIndex=" + str(next_index))
|
||||
|
||||
return _get(
|
||||
"GetUserItemApi",
|
||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserItemAll(
|
||||
user_id: int,
|
||||
*,
|
||||
item_kinds: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
|
||||
|
||||
参数:
|
||||
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
|
||||
|
||||
返回:
|
||||
{"userItemList": [...]},已合并所有类别与分页
|
||||
"""
|
||||
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
|
||||
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
|
||||
|
||||
merged: List[Dict[str, Any]] = []
|
||||
for kind in kinds:
|
||||
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
|
||||
page = 0
|
||||
while next_index:
|
||||
page += 1
|
||||
resp = _get(
|
||||
"GetUserItemApi",
|
||||
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
items = resp.get("userItemList") or []
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
merged.extend(items)
|
||||
|
||||
next_index = int(resp.get("nextIndex") or 0)
|
||||
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
|
||||
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
|
||||
if page >= 200:
|
||||
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
|
||||
break
|
||||
|
||||
log_info("道具获取完成:" + str(len(merged)) + " 条")
|
||||
return {"userItemList": merged}
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
|
||||
响应 {userId, userKaleidxScopeList:[...]}
|
||||
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
|
||||
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
|
||||
source/2026-10-05.log:15,119
|
||||
|
||||
★★ 重要区分(曾导致 userData 取空)
|
||||
--------------------------------------------------------------------------------
|
||||
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
|
||||
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
|
||||
|
||||
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
|
||||
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
|
||||
多发字段会让服务端返回空内容。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserKaleidxScope, GetGameKaleidxScope
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserKaleidxScope(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userKaleidxScopeList
|
||||
"""
|
||||
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
|
||||
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
|
||||
def GetGameKaleidxScope(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
|
||||
|
||||
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameKaleidxScopeList
|
||||
"""
|
||||
log_info("取全服 KALEIDX 门进度")
|
||||
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的登录奖励领取进度(真机 38 条,分两页)。
|
||||
|
||||
对应接口:GetUserLoginBonusApi
|
||||
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
|
||||
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserLoginBonus, GetUserLoginBonusAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserLoginBonus(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userLoginBonusList
|
||||
"""
|
||||
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserLoginBonusApi",
|
||||
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserLoginBonusAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户登录奖励(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userLoginBonusList": [...]}
|
||||
"""
|
||||
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
|
||||
items = get_all_pages(
|
||||
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
|
||||
body_factory=lambda ni: UserLoginBonusRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=20),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
|
||||
return {"userLoginBonusList": items}
|
||||
@@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各地图的推进距离(真机 104 条)。
|
||||
|
||||
对应接口:GetUserMapApi
|
||||
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
|
||||
响应 :{userId, length, nextIndex, userMapList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
|
||||
|
||||
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
|
||||
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserMap / GetUserMapAll
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserMap(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地图进度(单页,对应 GetUserMapApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userMapList
|
||||
"""
|
||||
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserMapApi",
|
||||
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserMapAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地图进度(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userMapList": [...]}
|
||||
"""
|
||||
log_info("取用户地图进度(全量):user_id=" + str(user_id))
|
||||
items = get_all_pages(
|
||||
"GetUserMapApi", user_id, "userMapList",
|
||||
body_factory=lambda ni: UserIdPageRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=1000),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("地图进度获取完成:" + str(len(items)) + " 条")
|
||||
return {"userMapList": items}
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的任务进度与周常数据(真机 6 条任务)。
|
||||
|
||||
对应接口:GetUserMissionDataApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
|
||||
|
||||
★ 一个接口同时下发两个 Upsert 节点:
|
||||
userMissionDataList 任务列表
|
||||
userWeeklyData 周常数据(★ 是**对象**不是数组)
|
||||
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserMissionData
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserMissionData(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户任务与周数据(对应 GetUserMissionDataApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
|
||||
"""
|
||||
log_info("取用户任务与周数据:user_id=" + str(user_id))
|
||||
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家全部曲目的历史成绩归档(真机 739 组)。
|
||||
|
||||
对应接口:GetUserMusicApi
|
||||
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
|
||||
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/GetUserMusicDetail.cs(内层按难度)
|
||||
|
||||
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
|
||||
按 (musicId, level) 的成绩条目。取数时要摊平。
|
||||
|
||||
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserMusicDetail, UserMusicDetailFlat
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserMusicDetail(
|
||||
user_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户曲目成绩(单页,对应 GetUserMusicApi)。
|
||||
|
||||
参数:
|
||||
next_index: 分页起始索引(0 = 首页)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userMusicList(两层嵌套)
|
||||
"""
|
||||
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
|
||||
return _get(
|
||||
"GetUserMusicApi",
|
||||
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserMusicDetailAll(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户曲目成绩(自动翻页取全,已摊平成单层)。
|
||||
|
||||
返回:
|
||||
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
|
||||
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
|
||||
"""
|
||||
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
|
||||
groups = get_all_pages(
|
||||
"GetUserMusicApi", user_id, "userMusicList",
|
||||
body_factory=lambda ni: UserMusicRequestVO(
|
||||
userId=user_id, nextIndex=ni or 0, maxCount=50),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
flat: List[Dict[str, Any]] = []
|
||||
for group in groups:
|
||||
if isinstance(group, dict):
|
||||
details = group.get("userMusicDetailList") or []
|
||||
if isinstance(details, dict):
|
||||
details = [details]
|
||||
flat.extend(details)
|
||||
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
|
||||
+ str(len(groups)) + " 组)")
|
||||
return {"userMusicList": flat}
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
|
||||
================================================================================
|
||||
|
||||
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
|
||||
|
||||
对应接口:GetUserNewItemListApi
|
||||
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
|
||||
响应 :{userId, userItemList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
|
||||
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
|
||||
|
||||
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
|
||||
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserNewItemList
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserNewItemListRequestVO:
|
||||
"""请求 VO(对应 UserNewItemListRequestVO)。"""
|
||||
userId: int
|
||||
version: int = PLAY_VERSION_INT
|
||||
userData: List[Any] = field(default_factory=list)
|
||||
userPlaylogList: List[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
def GetUserNewItemList(
|
||||
user_id: int,
|
||||
*,
|
||||
version: int = PLAY_VERSION_INT,
|
||||
user_data: Optional[List[Any]] = None,
|
||||
user_playlog_list: Optional[List[Any]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取本次新增道具(对应 GetUserNewItemListApi)。
|
||||
|
||||
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
|
||||
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
|
||||
|
||||
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
|
||||
真机实证(source/2026-10-05-new.log 的该请求):
|
||||
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
|
||||
userData[0].point = 4460 (登录时是 4110,本局后 4460)
|
||||
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
|
||||
即:**与 UpsertUserAll 里的 userData 完全一致**。
|
||||
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
|
||||
user_data: 玩家档案数组(通常传 [游玩后的 userData])
|
||||
user_playlog_list: 本局成绩数组(通常传 [playlog])
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userItemList
|
||||
"""
|
||||
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
|
||||
return request_dict(
|
||||
"GetUserNewItemListApi",
|
||||
UserNewItemListRequestVO(
|
||||
userId=user_id, version=version,
|
||||
userData=list(user_data or []),
|
||||
userPlaylogList=list(user_playlog_list or []),
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
|
||||
|
||||
对应接口:GetUserOptionApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :userOption 的字段直接平铺在响应顶层
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
|
||||
|
||||
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
|
||||
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
|
||||
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserOption
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserOption(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户游戏设置(对应 GetUserOptionApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,设置字段平铺在顶层
|
||||
"""
|
||||
log_info("取用户游戏设置:user_id=" + str(user_id))
|
||||
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,156 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
|
||||
================================================================================
|
||||
|
||||
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
|
||||
|
||||
对应接口:GetUserPreviewApi
|
||||
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
|
||||
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
|
||||
|
||||
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
|
||||
(source/2026-10-05.log:23)。
|
||||
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
|
||||
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserPreview
|
||||
pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
|
||||
print(pv.userName, pv.playerRating)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPreviewRequestVO:
|
||||
"""请求 VO(对应 UserPreviewRequestVO)。"""
|
||||
userId: int
|
||||
segaIdAuthKey: str = ""
|
||||
token: str = ""
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserPreviewResponseVO:
|
||||
"""
|
||||
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
|
||||
|
||||
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
|
||||
与 source/2026-10-05.log:24 的真机响应合并。
|
||||
"""
|
||||
userId: int = 0
|
||||
userName: str = ""
|
||||
isLogin: bool = False
|
||||
lastGameId: Optional[str] = None
|
||||
lastRomVersion: str = ""
|
||||
lastDataVersion: str = ""
|
||||
lastLoginDate: str = ""
|
||||
lastPlayDate: str = ""
|
||||
playerRating: int = 0
|
||||
nameplateId: int = 0
|
||||
iconId: int = 0
|
||||
trophyId: int = 0
|
||||
partnerId: int = 0
|
||||
frameId: int = 0
|
||||
dispRate: int = 0
|
||||
totalAwake: int = 0
|
||||
isNetMember: int = 0
|
||||
dailyBonusDate: str = ""
|
||||
headPhoneVolume: Optional[int] = None
|
||||
isInherit: bool = False
|
||||
banState: int = 0
|
||||
errorId: int = 0
|
||||
|
||||
|
||||
def GetUserPreview(
|
||||
user_id: int,
|
||||
token: str,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
sega_id_auth_key: str = "",
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserPreviewResponseVO:
|
||||
"""
|
||||
获取用户预览信息。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填,来自 ParseQRCode)
|
||||
client_id: 机台短值(可选)
|
||||
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserPreviewResponseVO
|
||||
"""
|
||||
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
|
||||
|
||||
raw = request_dict(
|
||||
"GetUserPreviewApi",
|
||||
UserPreviewRequestVO(
|
||||
userId=user_id,
|
||||
segaIdAuthKey=sega_id_auth_key,
|
||||
token=token,
|
||||
clientId=client_id,
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
|
||||
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
|
||||
|
||||
if resp.errorId != 0:
|
||||
log_warning("服务器返回 errorId=" + str(resp.errorId))
|
||||
if resp.banState != 0:
|
||||
log_warning("账号可能被封禁:banState=" + str(resp.banState))
|
||||
if resp.isLogin:
|
||||
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
|
||||
|
||||
log_info(
|
||||
"用户预览获取完成:userName=" + resp.userName
|
||||
+ " playerRating=" + str(resp.playerRating)
|
||||
+ " isLogin=" + str(resp.isLogin)
|
||||
+ " isNetMember=" + str(resp.isNetMember)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .LogSetup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userpreview_test")
|
||||
try:
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
|
||||
return 1
|
||||
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
|
||||
pv = GetUserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
|
||||
print("")
|
||||
for name in UserPreviewResponseVO.__dataclass_fields__:
|
||||
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("获取用户预览失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
|
||||
|
||||
对应接口:GetUserRatingApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
|
||||
nextNewRatingList, udemae}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserRating
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRating(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户 Rating 明细(对应 GetUserRatingApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRating 子对象
|
||||
"""
|
||||
log_info("取用户 Rating 明细:user_id=" + str(user_id))
|
||||
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
|
||||
响应 {userId, userRecommendRateMusicIdList:[...]}
|
||||
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
|
||||
响应 {userId, userRecommendSelectionMusicIdList:[...]}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
|
||||
source/2026-10-05.log:79,81
|
||||
|
||||
调用示例:
|
||||
from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRecommendRateMusic(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRecommendRateMusicIdList
|
||||
"""
|
||||
log_info("取推荐上分曲目:user_id=" + str(user_id))
|
||||
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
|
||||
|
||||
def GetUserRecommendSelectMusic(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRecommendSelectionMusicIdList
|
||||
"""
|
||||
log_info("取推荐选曲:user_id=" + str(user_id))
|
||||
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各地区的游玩次数。
|
||||
|
||||
对应接口:GetUserRegionApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
|
||||
source/2026-10-05.log:77
|
||||
|
||||
调用示例:
|
||||
from api import GetUserRegion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRegion(
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户地区游玩统计(对应 GetUserRegionApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRegionList
|
||||
"""
|
||||
log_info("取用户地区统计:user_id=" + str(user_id))
|
||||
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的对手名单及其曲目成绩。
|
||||
|
||||
对应接口:
|
||||
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
|
||||
响应 {userId, userRivalData:{rivalId, rivalName}}
|
||||
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
|
||||
+ userRivalMusicLevelList)
|
||||
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
|
||||
依据:
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
|
||||
source/2026-10-05.log:95,101
|
||||
|
||||
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserRivalData, GetUserRivalMusic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import (
|
||||
_get,
|
||||
UserRivalRequestVO,
|
||||
UserRivalMusicRequestVO,
|
||||
RIVAL_MUSIC_LEVELS,
|
||||
get_all_pages,
|
||||
)
|
||||
|
||||
|
||||
def GetUserRivalData(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手资料(对应 GetUserRivalDataApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
rival_id: 对手 ID(必填)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRivalData
|
||||
"""
|
||||
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
||||
return _get(
|
||||
"GetUserRivalDataApi",
|
||||
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserRivalMusic(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
next_index: int = 0,
|
||||
levels: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
rival_id: 对手 ID(必填)
|
||||
next_index: 分页索引(0 = 首页)
|
||||
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userRivalMusicList
|
||||
"""
|
||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
||||
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
|
||||
+ " levels=" + str(lv))
|
||||
return _get(
|
||||
"GetUserRivalMusicApi",
|
||||
UserRivalMusicRequestVO(
|
||||
userId=user_id, rivalId=rival_id, nextIndex=next_index,
|
||||
userRivalMusicLevelList=[{"level": x} for x in lv],
|
||||
),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
|
||||
|
||||
def GetUserRivalMusicAll(
|
||||
user_id: int,
|
||||
rival_id: int,
|
||||
*,
|
||||
levels: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取对手曲目成绩(自动翻页取全)。
|
||||
|
||||
返回:
|
||||
{"userRivalMusicList": [...]}
|
||||
"""
|
||||
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
|
||||
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
|
||||
items = get_all_pages(
|
||||
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
|
||||
body_factory=lambda ni: UserRivalMusicRequestVO(
|
||||
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
|
||||
userRivalMusicLevelList=[{"level": x} for x in lv]),
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
|
||||
return {"userRivalMusicList": items}
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各商店商品的已兑换次数。
|
||||
|
||||
对应接口:GetUserShopStockApi
|
||||
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
|
||||
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
|
||||
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
|
||||
|
||||
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
|
||||
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserShopStock
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserShopStockRequestVO
|
||||
|
||||
|
||||
def GetUserShopStock(
|
||||
user_id: int,
|
||||
*,
|
||||
shop_item_id_list: Optional[List[int]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取用户商店库存(对应 GetUserShopStockApi)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 userShopStockList
|
||||
"""
|
||||
ids = list(shop_item_id_list) if shop_item_id_list is not None \
|
||||
else list(PLAY_SHOP_ITEM_IDS)
|
||||
|
||||
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
|
||||
return _get(
|
||||
"GetUserShopStockApi",
|
||||
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
|
||||
)
|
||||
@@ -0,0 +1,704 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
|
||||
================================================================================
|
||||
|
||||
【两种模式】
|
||||
|
||||
INFO (默认) 只展示 api 脚本的运行情况和结果:
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] ...
|
||||
[调用参数] ...
|
||||
[运行结果] ...
|
||||
=== ParseQRCode 运行结束 ===
|
||||
|
||||
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
|
||||
-> 请求 GetUserPreviewApi
|
||||
URL : https://...
|
||||
请求头 : {...}
|
||||
请求体 : {...JSON 全文...}
|
||||
<- 响应 GetUserPreviewApi
|
||||
状态码 : 200
|
||||
响应体 : {...JSON 全文...}
|
||||
|
||||
【模式从哪来】
|
||||
|
||||
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
|
||||
|
||||
日常只改 config.py 一行:
|
||||
|
||||
LOG_LEVEL = "info" # 只看流程
|
||||
LOG_LEVEL = "debug" # 连收发数据一起看
|
||||
|
||||
【日志文件(两种形式同时存在、同时写入)】
|
||||
|
||||
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
|
||||
下一次运行主脚本时被覆盖
|
||||
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
|
||||
|
||||
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
|
||||
|
||||
【主脚本怎么用】
|
||||
|
||||
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
|
||||
|
||||
def main():
|
||||
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
|
||||
try:
|
||||
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
|
||||
login = UserLogin(user_id, token)
|
||||
finally:
|
||||
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
|
||||
|
||||
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
|
||||
统一套上了日志外壳(见 autowrap)。
|
||||
|
||||
【api 脚本怎么用】
|
||||
|
||||
from api import log_info, log_debug, log_request, log_response
|
||||
|
||||
def GetUserPreview(user_id, token, ...):
|
||||
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
|
||||
# 例如: '''获取用户预览信息'''
|
||||
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
|
||||
|
||||
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
|
||||
raw = _http_post(...)
|
||||
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
|
||||
|
||||
return parsed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
# =============================================================================
|
||||
# 常量与全局状态
|
||||
# =============================================================================
|
||||
|
||||
LEVEL_INFO = "info"
|
||||
LEVEL_DEBUG = "debug"
|
||||
|
||||
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
|
||||
|
||||
# 项目根目录(本文件在 api/ 下,根目录是上一级)
|
||||
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
|
||||
|
||||
_SEPARATOR = "=" * 66
|
||||
_INDENT = " "
|
||||
|
||||
# 全局状态
|
||||
_session_started = False
|
||||
_session_script_name = ""
|
||||
_current_level = LEVEL_INFO
|
||||
_value_max = 0
|
||||
|
||||
_date_log_path: Optional[str] = None
|
||||
_latest_log_path: Optional[str] = None
|
||||
_date_log_file = None
|
||||
_latest_log_file = None
|
||||
|
||||
# 被自动跟踪的 api 函数名(供横幅展示)
|
||||
_tracked_api_names: list = []
|
||||
# 本次会话实际调用过的 api(按首次调用顺序)
|
||||
_called_apis: list = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 模式判定
|
||||
# =============================================================================
|
||||
|
||||
def _normalize_level(level: Any) -> str:
|
||||
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
|
||||
if level is None:
|
||||
return LEVEL_INFO
|
||||
text = str(level).strip().lower()
|
||||
if text in ("debug", "dbg", "verbose", "详细", "调试"):
|
||||
return LEVEL_DEBUG
|
||||
return LEVEL_INFO
|
||||
|
||||
|
||||
def _config_level() -> str:
|
||||
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
|
||||
try:
|
||||
import config # type: ignore
|
||||
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
|
||||
except Exception:
|
||||
return LEVEL_INFO
|
||||
|
||||
|
||||
def _config_value_max() -> int:
|
||||
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
|
||||
try:
|
||||
import config # type: ignore
|
||||
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def get_level() -> str:
|
||||
"""返回当前日志模式('info' / 'debug')。"""
|
||||
return _current_level
|
||||
|
||||
|
||||
def is_debug() -> bool:
|
||||
"""当前是否 DEBUG 模式。"""
|
||||
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
|
||||
|
||||
|
||||
def set_level(level: Any) -> str:
|
||||
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
|
||||
global _current_level
|
||||
_current_level = _normalize_level(level)
|
||||
return _current_level
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 日志文件
|
||||
# =============================================================================
|
||||
|
||||
def _ensure_logs_dir() -> None:
|
||||
os.makedirs(_LOGS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def _get_date_log_path() -> str:
|
||||
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
|
||||
|
||||
|
||||
def _get_latest_log_path() -> str:
|
||||
return os.path.join(_LOGS_DIR, "latest.log")
|
||||
|
||||
|
||||
def _write_files(message: str) -> None:
|
||||
"""把一段文本写进两个日志文件(每行带时间戳)。"""
|
||||
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
|
||||
lines = message.splitlines() or [""]
|
||||
payload = "".join(timestamp + line + "\n" for line in lines)
|
||||
for handle in (_date_log_file, _latest_log_file):
|
||||
if handle:
|
||||
try:
|
||||
handle.write(payload)
|
||||
handle.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit(message: str = "", *, console: bool = True) -> None:
|
||||
"""统一出口:控制台 + 两个日志文件。"""
|
||||
if console:
|
||||
try:
|
||||
print(message)
|
||||
except Exception:
|
||||
try:
|
||||
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
pass
|
||||
_write_files(message)
|
||||
|
||||
|
||||
def _close_log_files() -> None:
|
||||
global _date_log_file, _latest_log_file
|
||||
for name in ("_date_log_file", "_latest_log_file"):
|
||||
handle = globals()[name]
|
||||
if handle:
|
||||
try:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
globals()[name] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 自动跟踪:给 api 函数套日志外壳
|
||||
# =============================================================================
|
||||
#
|
||||
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
|
||||
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
|
||||
# 调用方不必自己写装饰器,也不必自己埋点。
|
||||
#
|
||||
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
|
||||
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
|
||||
# 拿到的已经是包装后的函数。
|
||||
|
||||
def _describe(func: Callable) -> str:
|
||||
"""取函数 docstring 首行,作为「[接口说明]」。"""
|
||||
doc = (func.__doc__ or "").strip()
|
||||
if not doc:
|
||||
return ""
|
||||
return doc.splitlines()[0].strip()
|
||||
|
||||
|
||||
def _make_tracked(func: Callable) -> Callable:
|
||||
"""生成带日志外壳的包装函数。"""
|
||||
name = func.__name__
|
||||
summary = _describe(func)
|
||||
|
||||
@functools.wraps(func)
|
||||
def tracked(*args, **kwargs) -> Any:
|
||||
_note_api_call(name)
|
||||
|
||||
_emit("")
|
||||
_emit("=== " + name + " 开始运行 ===")
|
||||
if summary:
|
||||
_emit(_INDENT + "[接口说明] " + summary)
|
||||
|
||||
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
|
||||
if args:
|
||||
if is_debug():
|
||||
_emit(_INDENT + "[位置参数]")
|
||||
_emit(_fmt_pretty(list(args)))
|
||||
else:
|
||||
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
|
||||
if kwargs:
|
||||
shown = {k: v for k, v in kwargs.items() if v is not None}
|
||||
if shown:
|
||||
if is_debug():
|
||||
_emit(_INDENT + "[关键字参数]")
|
||||
_emit(_fmt_pretty(shown))
|
||||
else:
|
||||
_emit(_INDENT + "[关键字参数] "
|
||||
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
except BaseException as exc:
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
|
||||
+ str(exc) + "(耗时 " + str(ms) + " ms)")
|
||||
_emit("=== " + name + " 运行结束 ===")
|
||||
_emit("")
|
||||
raise
|
||||
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
|
||||
+ "(耗时 " + str(ms) + " ms)")
|
||||
if is_debug() and result is not None:
|
||||
_emit(_INDENT + "[返回数据]")
|
||||
_emit(_fmt_pretty(result))
|
||||
_emit("=== " + name + " 运行结束 ===")
|
||||
_emit("")
|
||||
return result
|
||||
|
||||
tracked.__mai_logged__ = True # type: ignore[attr-defined]
|
||||
tracked.__mai_original__ = func # type: ignore[attr-defined]
|
||||
return tracked
|
||||
|
||||
|
||||
def autowrap(namespace: dict, names: Iterable[str]) -> list:
|
||||
"""
|
||||
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
|
||||
|
||||
参数:
|
||||
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
|
||||
names: 要包装的函数名序列
|
||||
|
||||
返回:
|
||||
实际完成包装的函数名列表
|
||||
"""
|
||||
wrapped = []
|
||||
for name in names:
|
||||
obj = namespace.get(name)
|
||||
if obj is None or not callable(obj):
|
||||
continue
|
||||
if getattr(obj, "__mai_logged__", False):
|
||||
wrapped.append(name)
|
||||
continue
|
||||
try:
|
||||
namespace[name] = _make_tracked(obj)
|
||||
wrapped.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for n in wrapped:
|
||||
if n not in _tracked_api_names:
|
||||
_tracked_api_names.append(n)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _note_api_call(name: str) -> None:
|
||||
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
|
||||
if name not in _called_apis:
|
||||
_called_apis.append(name)
|
||||
|
||||
|
||||
def get_called_apis() -> list:
|
||||
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
|
||||
return list(_called_apis)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 会话:初始化 / 收尾
|
||||
# =============================================================================
|
||||
|
||||
def setup_logging(script_name: str, level: Any = None) -> str:
|
||||
"""
|
||||
初始化日志会话(主脚本开头调用一次)。
|
||||
|
||||
做三件事:
|
||||
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
|
||||
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
|
||||
3. 打印会话横幅
|
||||
|
||||
参数:
|
||||
script_name: 脚本名,显示在横幅上
|
||||
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
|
||||
|
||||
返回:
|
||||
实际生效的模式('info' / 'debug')
|
||||
"""
|
||||
global _session_started, _session_script_name, _value_max
|
||||
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
|
||||
|
||||
set_level(level if level is not None else _config_level())
|
||||
_value_max = _config_value_max()
|
||||
|
||||
if _session_started:
|
||||
return _current_level
|
||||
|
||||
_ensure_logs_dir()
|
||||
_session_script_name = script_name
|
||||
_date_log_path = _get_date_log_path()
|
||||
_latest_log_path = _get_latest_log_path()
|
||||
|
||||
_called_apis.clear()
|
||||
|
||||
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
|
||||
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
|
||||
|
||||
_print_banner(script_name)
|
||||
_session_started = True
|
||||
return _current_level
|
||||
|
||||
|
||||
def _print_banner(script_name: str) -> None:
|
||||
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
|
||||
lines = [
|
||||
_SEPARATOR,
|
||||
_INDENT + "日志会话开始 —— " + script_name,
|
||||
_SEPARATOR,
|
||||
_INDENT * 2 + "主脚本 : " + script_name,
|
||||
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
_INDENT * 2 + "日志模式 : " + mode_text,
|
||||
_INDENT * 2 + "Python : " + platform.python_version()
|
||||
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
|
||||
_INDENT * 2 + "工作目录 : " + os.getcwd(),
|
||||
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
|
||||
+ " 个(自动跟踪,主脚本无需写日志代码)",
|
||||
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
|
||||
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
|
||||
_SEPARATOR,
|
||||
]
|
||||
_emit("\n".join(lines))
|
||||
|
||||
|
||||
def teardown_logging() -> None:
|
||||
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
|
||||
global _session_started
|
||||
if not _session_started:
|
||||
return
|
||||
|
||||
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
|
||||
if _called_apis:
|
||||
for i, name in enumerate(_called_apis, 1):
|
||||
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
|
||||
else:
|
||||
lines.append(_INDENT * 2 + "(未调用任何 api)")
|
||||
lines.append(_SEPARATOR)
|
||||
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
|
||||
lines.append(_SEPARATOR)
|
||||
|
||||
_emit("\n".join(lines))
|
||||
_close_log_files()
|
||||
_session_started = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 值格式化
|
||||
# =============================================================================
|
||||
|
||||
def _truncate(text: str) -> str:
|
||||
if _value_max and len(text) > _value_max:
|
||||
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
|
||||
return text
|
||||
|
||||
|
||||
def _fmt(value: Any) -> str:
|
||||
"""把一个值渲染成紧凑单行文本。"""
|
||||
if isinstance(value, str):
|
||||
return _truncate(value)
|
||||
if value is None or isinstance(value, (int, float, bool)):
|
||||
return repr(value)
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
try:
|
||||
return _truncate(json.dumps(value, ensure_ascii=False,
|
||||
separators=(",", ":"), default=str))
|
||||
except Exception:
|
||||
return _truncate(repr(value))
|
||||
if hasattr(value, "__dict__"):
|
||||
try:
|
||||
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return _truncate(json.dumps(data, ensure_ascii=False,
|
||||
separators=(",", ":"), default=str))
|
||||
except Exception:
|
||||
return _truncate(repr(value))
|
||||
return _truncate(repr(value))
|
||||
|
||||
|
||||
def _to_plain(value: Any) -> Any:
|
||||
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
|
||||
if value is None or isinstance(value, (int, float, bool, str)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {k: _to_plain(v) for k, v in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_to_plain(v) for v in value]
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _fmt_pretty(value: Any, indent: str = " ") -> str:
|
||||
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
|
||||
data = _to_plain(value)
|
||||
if isinstance(data, str):
|
||||
stripped = data.strip()
|
||||
if stripped[:1] in ("{", "["):
|
||||
try:
|
||||
data = json.loads(stripped)
|
||||
except Exception:
|
||||
return "\n".join(indent + line for line in data.splitlines())
|
||||
try:
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
||||
except Exception:
|
||||
text = repr(data)
|
||||
|
||||
if _value_max and len(text) > _value_max:
|
||||
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
|
||||
return "\n".join(indent + line for line in text.splitlines())
|
||||
|
||||
|
||||
def _fmt_result(result: Any) -> str:
|
||||
"""结果的紧凑摘要(INFO 用)。"""
|
||||
if isinstance(result, list):
|
||||
return "list(" + str(len(result)) + " 项)"
|
||||
if isinstance(result, tuple):
|
||||
return "tuple(" + str(len(result)) + " 项)"
|
||||
if isinstance(result, dict):
|
||||
return "dict(" + str(len(result)) + " 键)"
|
||||
if isinstance(result, str):
|
||||
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
|
||||
if hasattr(result, "__dataclass_fields__"):
|
||||
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
|
||||
return repr(result)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 流程日志(INFO 可见)
|
||||
# =============================================================================
|
||||
|
||||
def log_info(message: str) -> None:
|
||||
"""普通信息 —— 相当于「会进日志文件的 print」。"""
|
||||
_emit(str(message))
|
||||
|
||||
|
||||
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
|
||||
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
|
||||
if index and total:
|
||||
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
|
||||
elif index:
|
||||
_emit("[步骤 " + str(index) + "] " + title)
|
||||
else:
|
||||
_emit("[步骤] " + title)
|
||||
|
||||
|
||||
def log_result(message: Any) -> None:
|
||||
"""记一条运行结果。"""
|
||||
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
|
||||
|
||||
|
||||
def log_warning(message: str) -> None:
|
||||
"""警告(不影响流程,但需要留意)。"""
|
||||
_emit("[警告] " + str(message))
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
"""错误。"""
|
||||
_emit("[错误] " + str(message))
|
||||
|
||||
|
||||
def log_section(title: str) -> None:
|
||||
"""记一个分节(带分隔线),用于主脚本分段。"""
|
||||
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
|
||||
|
||||
|
||||
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
|
||||
"""
|
||||
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
|
||||
|
||||
log_field("user_id", 12345)
|
||||
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
|
||||
"""
|
||||
if debug_only and not is_debug():
|
||||
return
|
||||
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
|
||||
_emit(name + ":")
|
||||
_emit(_fmt_pretty(value))
|
||||
return
|
||||
_emit(name + ": " + _fmt(value))
|
||||
|
||||
|
||||
def log_debug(message: Any) -> None:
|
||||
"""调试信息 —— 仅 DEBUG 模式输出。"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("[调试] " + _fmt(message))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
|
||||
# =============================================================================
|
||||
|
||||
def log_request(
|
||||
url: str = "",
|
||||
body: Any = None,
|
||||
headers: Any = None,
|
||||
*,
|
||||
api_name: str = "",
|
||||
) -> None:
|
||||
"""
|
||||
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
|
||||
|
||||
参数:
|
||||
url: 完整请求地址
|
||||
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
|
||||
headers: 请求头(dict)
|
||||
api_name: 接口名
|
||||
"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit("-> 请求 " + api_name if api_name else "-> 请求")
|
||||
if url:
|
||||
_emit(" URL : " + str(url))
|
||||
if headers:
|
||||
_emit(" 请求头 : " + _fmt(headers))
|
||||
if body is not None:
|
||||
_emit(" 请求体 :")
|
||||
_emit(_fmt_pretty(body))
|
||||
|
||||
|
||||
def log_response(
|
||||
status: Any = None,
|
||||
body: Any = None,
|
||||
headers: Any = None,
|
||||
*,
|
||||
api_name: str = "",
|
||||
elapsed_ms: Optional[int] = None,
|
||||
) -> None:
|
||||
"""
|
||||
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
|
||||
|
||||
参数:
|
||||
status: HTTP 状态码
|
||||
body: 响应体(JSON 字符串 / dict / VO)
|
||||
headers: 响应头(dict)
|
||||
api_name: 接口名
|
||||
elapsed_ms: 耗时(毫秒)
|
||||
"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit("<- 响应 " + api_name if api_name else "<- 响应")
|
||||
if status is not None:
|
||||
_emit(" 状态码 : " + str(status))
|
||||
if elapsed_ms is not None:
|
||||
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
|
||||
if headers:
|
||||
_emit(" 响应头 : " + _fmt(headers))
|
||||
if body is not None:
|
||||
_emit(" 响应体 :")
|
||||
_emit(_fmt_pretty(body))
|
||||
|
||||
|
||||
def log_payload(label: str, data: Any) -> None:
|
||||
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
|
||||
if not is_debug():
|
||||
return
|
||||
_emit("")
|
||||
_emit(" " + label + " :")
|
||||
_emit(_fmt_pretty(data))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 兼容:装饰器与 logger 对象
|
||||
# =============================================================================
|
||||
|
||||
def api_log(func: Callable) -> Callable:
|
||||
"""
|
||||
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
|
||||
|
||||
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
|
||||
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
|
||||
"""
|
||||
if getattr(func, "__mai_logged__", False):
|
||||
return func
|
||||
return _make_tracked(func)
|
||||
|
||||
|
||||
class _Logger:
|
||||
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
log_info(message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
log_warning(message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
log_error(message)
|
||||
|
||||
def debug(self, message: Any) -> None:
|
||||
log_debug(message)
|
||||
|
||||
def step(self, title: str, **kwargs) -> None:
|
||||
log_step(title, **kwargs)
|
||||
|
||||
def result(self, message: Any) -> None:
|
||||
log_result(message)
|
||||
|
||||
|
||||
_logger_instance = _Logger()
|
||||
|
||||
|
||||
def get_logger() -> _Logger:
|
||||
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
|
||||
return _logger_instance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 退出时自动收尾
|
||||
# =============================================================================
|
||||
|
||||
def _auto_teardown() -> None:
|
||||
try:
|
||||
if _session_started:
|
||||
teardown_logging()
|
||||
else:
|
||||
_close_log_files()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
atexit.register(_auto_teardown)
|
||||
@@ -0,0 +1,820 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
================================================================================
|
||||
|
||||
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
一、协议依据(全部来自 source/ 反编译源码)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
1) 服务器基址与 API 路径
|
||||
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:116
|
||||
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:485
|
||||
Obfuscator(s) = md5_hex_lower(s + "<REDACTED>")
|
||||
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
|
||||
Api = api + "MaimaiChn"
|
||||
|
||||
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "<REDACTED>")
|
||||
|
||||
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "<REDACTED>")
|
||||
= 83a5d5a20b062c5c2ad817460b8f7f76
|
||||
(source/2026-10-05.log:1 的请求就是打到这个尾段)
|
||||
|
||||
2) 请求体封装(三段:JSON -> deflate -> AES)
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
|
||||
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
|
||||
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
|
||||
c. CipherAES.Encrypt(...) 整体 AES 加密
|
||||
source/Assembly-CSharp/Net/CipherAES.cs:11-30
|
||||
AES-256-CBC / PKCS7 / Key 32B / IV 16B
|
||||
|
||||
3) 响应体解封装(逆过程)
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
|
||||
a. AES 解密
|
||||
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
|
||||
c. Deflate 解压 -> UTF-8 JSON
|
||||
|
||||
4) 请求头
|
||||
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
|
||||
Content-Type : application/json
|
||||
charset : UTF-8
|
||||
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
|
||||
Content-Encoding: deflate (有请求体时)
|
||||
User-Agent : <混淆后的API名>#<userId 或 clientId>
|
||||
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
|
||||
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
|
||||
|
||||
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
|
||||
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
|
||||
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
|
||||
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
|
||||
没跑过时 number = 0,与真机开机后第一屏的行为一致。
|
||||
|
||||
★ Cookie:★ UserLoginApi 的响应会下发
|
||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
|
||||
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
|
||||
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
|
||||
这是**真实的会话粘滞**,必须带着。
|
||||
requests.Session 会自动收下并自动带上,无需额外代码 ——
|
||||
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
|
||||
--------------------------------------------------------------------------------
|
||||
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
|
||||
|
||||
number / Content-Type / User-Agent / charset / Mai-Encoding /
|
||||
Content-Encoding / (+ 登录后的 Cookie)
|
||||
|
||||
**没有 Accept,也没有 Connection。**
|
||||
|
||||
requests 默认会补 Accept: * / Connection: keep-alive,
|
||||
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
|
||||
|
||||
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
|
||||
|
||||
实测(GetGameSettingApi,2026-10-06):
|
||||
Accept-Encoding: "" -> 200 + application/json
|
||||
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
|
||||
|
||||
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
|
||||
(Headers.AllKeys 里看不到它,是框架层加的)。
|
||||
所以这里固定 Accept-Encoding: ""。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
三、对外接口
|
||||
--------------------------------------------------------------------------------
|
||||
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
|
||||
get_default_client() 取全局默认客户端
|
||||
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
|
||||
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
|
||||
|
||||
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
|
||||
协议工具 :obfuscate_api_name / build_api_url / adler32
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import zlib
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
|
||||
# =============================================================================
|
||||
|
||||
#: 游戏服务器(Maimai2Servlet)
|
||||
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("<REDACTED>/")
|
||||
BASE_URL = ""
|
||||
|
||||
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
|
||||
AES_KEY = b""
|
||||
AES_IV = b""
|
||||
|
||||
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
|
||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
OBFUSCATE_PARAM = ""
|
||||
|
||||
from .LogSetup import log_debug, log_error, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量
|
||||
# =============================================================================
|
||||
|
||||
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
|
||||
API_SUFFIX = "MaimaiChn"
|
||||
|
||||
#: HTTP 头(NetHttpClient.cs:174-190)
|
||||
HEADER_CONTENT_TYPE = "application/json"
|
||||
HEADER_CHARSET = "UTF-8"
|
||||
HEADER_CONTENT_ENCODING = "deflate"
|
||||
|
||||
#: AES 参数
|
||||
AES_BLOCK_SIZE = 16
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、协议工具
|
||||
# =============================================================================
|
||||
|
||||
def obfuscate_api_name(api_name: str) -> str:
|
||||
"""
|
||||
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
|
||||
|
||||
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
|
||||
-> md5(上面 + "<REDACTED>")
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
|
||||
|
||||
返回:
|
||||
32 位小写十六进制字符串
|
||||
"""
|
||||
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
|
||||
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def build_api_url(api_name: str) -> str:
|
||||
"""构建完整的请求 URL。"""
|
||||
return urljoin(BASE_URL, obfuscate_api_name(api_name))
|
||||
|
||||
|
||||
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
|
||||
"""
|
||||
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
|
||||
|
||||
userId != 0 -> "<混淆API名>#<userId>"
|
||||
userId == 0 -> "<混淆API名>#<clientId>"
|
||||
"""
|
||||
tail = user_id if user_id else client_id
|
||||
return obfuscate_api_name(api_name) + "#" + str(tail)
|
||||
|
||||
|
||||
def adler32(data: bytes) -> int:
|
||||
"""
|
||||
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
|
||||
|
||||
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
|
||||
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
|
||||
"""
|
||||
a = 1
|
||||
b = 0
|
||||
for byte in data:
|
||||
a = (a + byte) % 65521
|
||||
b = (b + a) % 65521
|
||||
return (b << 16) | a
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、加解密 / 压缩
|
||||
# =============================================================================
|
||||
|
||||
def aes_encrypt(data: bytes) -> bytes:
|
||||
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
||||
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
|
||||
|
||||
|
||||
def aes_decrypt(data: bytes) -> bytes:
|
||||
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
|
||||
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
|
||||
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
|
||||
|
||||
|
||||
def deflate_compress(data: bytes) -> bytes:
|
||||
"""
|
||||
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
|
||||
|
||||
对应 NetHttpClient.cs:262-300 RequestCallback:
|
||||
memoryStream.WriteByte(120); // 0x78
|
||||
memoryStream.WriteByte(156); // 0x9C
|
||||
... 原始 deflate 数据 ...
|
||||
... Adler32 大端 4 字节 ...
|
||||
|
||||
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
|
||||
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
|
||||
"""
|
||||
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
|
||||
compressed = compressor.compress(data) + compressor.flush()
|
||||
checksum = adler32(data)
|
||||
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
|
||||
|
||||
|
||||
def deflate_decompress(data: bytes) -> bytes:
|
||||
"""
|
||||
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
|
||||
|
||||
对应 NetHttpClient.cs:355-380 Decompress:
|
||||
_temporaryStream.Position = 2; // 跳过 2 字节头
|
||||
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
|
||||
"""
|
||||
if len(data) < 6:
|
||||
return b""
|
||||
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、真机时间戳
|
||||
# =============================================================================
|
||||
|
||||
def local_utc_offset_seconds() -> int:
|
||||
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
|
||||
offset = -time.timezone
|
||||
if time.daylight and time.localtime().tm_isdst > 0:
|
||||
offset = -time.altzone
|
||||
return int(offset)
|
||||
|
||||
|
||||
def login_date_time_now() -> int:
|
||||
"""
|
||||
生成真机 UserLoginApi 的 loginDateTime。
|
||||
|
||||
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
|
||||
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
|
||||
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
|
||||
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
|
||||
|
||||
东八区即 now + 28800。
|
||||
|
||||
★ 为什么必须一致:
|
||||
PacketUserLogin.cs:43 会把请求值回填进响应,
|
||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
||||
与登录时不一致会被服务端拒绝。
|
||||
"""
|
||||
return int(time.time()) + local_utc_offset_seconds()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、VO 序列化 / 反序列化
|
||||
# =============================================================================
|
||||
|
||||
def vo_to_json(obj: Any) -> str:
|
||||
"""
|
||||
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
|
||||
|
||||
规则:
|
||||
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
|
||||
- 带 __dict__ 的对象 -> 同上
|
||||
- dict / 其他 -> 直接 json.dumps
|
||||
"""
|
||||
if obj is None:
|
||||
return "{}"
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
if isinstance(obj, dict):
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
if hasattr(obj, "__dict__"):
|
||||
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return {k: getattr(value, k) for k in value.__dataclass_fields__}
|
||||
if hasattr(value, "__dict__"):
|
||||
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _convert_value(value: Any, target_type: Any) -> Any:
|
||||
"""按目标类型递归转换嵌套 dataclass。"""
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
|
||||
kwargs = {}
|
||||
for fname, fdef in target_type.__dataclass_fields__.items():
|
||||
if fname in value:
|
||||
kwargs[fname] = _convert_value(value[fname], fdef.type)
|
||||
return target_type(**kwargs)
|
||||
|
||||
origin = getattr(target_type, "__origin__", None)
|
||||
if origin is list and isinstance(value, list):
|
||||
args = getattr(target_type, "__args__", None)
|
||||
if args and isinstance(args[0], type) and is_dataclass(args[0]):
|
||||
return [_convert_value(v, args[0]) for v in value]
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def vo_from_json(cls: Type[T], json_str: str) -> T:
|
||||
"""
|
||||
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
|
||||
|
||||
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
|
||||
"""
|
||||
data = json.loads(json_str)
|
||||
if not isinstance(data, dict):
|
||||
return data # type: ignore[return-value]
|
||||
|
||||
if is_dataclass(cls):
|
||||
kwargs = {}
|
||||
for fname, fdef in cls.__dataclass_fields__.items():
|
||||
if fname in data:
|
||||
kwargs[fname] = _convert_value(data[fname], fdef.type)
|
||||
return cls(**kwargs)
|
||||
|
||||
obj = cls() # type: ignore[call-arg]
|
||||
for k, v in data.items():
|
||||
if hasattr(obj, k):
|
||||
setattr(obj, k, v)
|
||||
return obj
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 五、网络客户端
|
||||
# =============================================================================
|
||||
|
||||
class MaimaiNetError(RuntimeError):
|
||||
"""网络层 / 协议层错误。"""
|
||||
|
||||
|
||||
class MaimaiNetClient:
|
||||
"""
|
||||
舞萌DX 游戏服务器客户端。
|
||||
|
||||
一次完整请求的流程:
|
||||
|
||||
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
|
||||
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
|
||||
|
||||
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
|
||||
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = BASE_URL,
|
||||
timeout_ms: int = TIMEOUT_MS,
|
||||
verify_ssl: bool = False,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/") + "/"
|
||||
self.timeout = timeout_ms / 1000.0
|
||||
self.verify_ssl = verify_ssl
|
||||
self.session = requests.Session()
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
|
||||
# ---------------------------------------------------------------------
|
||||
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
|
||||
# number / Content-Type / User-Agent / charset / Mai-Encoding /
|
||||
# Content-Encoding / (+ Cookie)
|
||||
# 没有 Accept,也没有 Connection。
|
||||
#
|
||||
# 而 requests 默认会补上:
|
||||
# Accept: */* <- 删掉(真机不发)
|
||||
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
|
||||
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
|
||||
#
|
||||
# ★ Accept-Encoding 为什么必须**存在且为空**:
|
||||
# 实测(GetGameSettingApi,2026-10-06):
|
||||
# Accept-Encoding: "" -> 200 + application/json(384 B)
|
||||
# 不发该头 / "gzip, deflate" / "identity"
|
||||
# -> 200 + application/octet-stream(0 B)
|
||||
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
|
||||
self.session.headers.pop("Accept", None)
|
||||
self.session.headers.pop("Connection", None)
|
||||
self.session.headers.pop("Accept-Encoding", None)
|
||||
|
||||
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
|
||||
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
|
||||
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
|
||||
self.ping_msec: int = 0
|
||||
|
||||
if not verify_ssl:
|
||||
try:
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 内部
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def _build_headers(
|
||||
self,
|
||||
api_name: str,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
has_body: bool = True,
|
||||
ping_msec: Optional[int] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
|
||||
|
||||
★ 关于 number 头:
|
||||
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
|
||||
|
||||
if (!IsPingPacket())
|
||||
{
|
||||
Client.AddHeader("number", GameManager.PingMsec.ToString());
|
||||
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
|
||||
}
|
||||
|
||||
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
|
||||
ping 毫秒数(GameManager.PingMsec)。
|
||||
|
||||
★ number 的来源(源码链路,逐条可查):
|
||||
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
|
||||
Net.Packet.Mai2/PacketPing.cs:34
|
||||
_onDone((int)sw.ElapsedMilliseconds)
|
||||
→ 值 = **Ping 包的往返毫秒**
|
||||
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
|
||||
|
||||
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
|
||||
实测(gamehook 抓真机一局 70 个请求):
|
||||
#1 ~ #10 number = 0 ← 还没跑过 Ping
|
||||
#11 ~ #17 Ping 心跳(不带 number 头)
|
||||
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
|
||||
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
|
||||
|
||||
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
|
||||
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
|
||||
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
|
||||
|
||||
★ 实测 **UserLoginApi 的响应头里确实有**:
|
||||
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
|
||||
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
|
||||
Content-Length / Connection / Pragma / Cache-Control)。
|
||||
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
|
||||
|
||||
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
|
||||
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
|
||||
"""
|
||||
headers = {
|
||||
"Content-Type": HEADER_CONTENT_TYPE,
|
||||
"charset": HEADER_CHARSET,
|
||||
"Mai-Encoding": MAI_ENCODING,
|
||||
"User-Agent": build_user_agent(api_name, user_id, client_id),
|
||||
# ★ 必须显式置空,见模块 docstring 第二节
|
||||
"Accept-Encoding": "",
|
||||
}
|
||||
if has_body:
|
||||
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
|
||||
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
|
||||
if not api_name.startswith("Ping"):
|
||||
value = self.ping_msec if ping_msec is None else ping_msec
|
||||
headers["number"] = str(int(value))
|
||||
return headers
|
||||
|
||||
@property
|
||||
def last_ping_msec(self) -> int:
|
||||
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
|
||||
return self.ping_msec
|
||||
|
||||
def _encrypt_request(self, json_str: str) -> bytes:
|
||||
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
|
||||
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
|
||||
|
||||
def _decrypt_response(self, encrypted: bytes) -> str:
|
||||
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
|
||||
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 公开
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def request(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""
|
||||
发送一次请求,返回**原始 JSON 响应字符串**。
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
|
||||
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
|
||||
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
|
||||
client_id: 客户端 ID
|
||||
timeout_ms: 超时覆盖(毫秒)
|
||||
extra_headers: 额外请求头(如 number)
|
||||
|
||||
返回:
|
||||
响应 JSON 字符串
|
||||
|
||||
异常:
|
||||
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
|
||||
"""
|
||||
url = urljoin(self.base_url, obfuscate_api_name(api_name))
|
||||
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
|
||||
|
||||
request_json = vo_to_json(request_vo)
|
||||
body = self._encrypt_request(request_json)
|
||||
|
||||
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
# ★ DEBUG 模式:记录「向 api 发送的数据」
|
||||
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
response = self.session.post(
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
verify=self.verify_ssl,
|
||||
)
|
||||
except requests.exceptions.RequestException as exc:
|
||||
log_error("接口 " + api_name + " 请求失败:" + str(exc))
|
||||
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
|
||||
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
|
||||
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
|
||||
if api_name.startswith("Ping"):
|
||||
self.ping_msec = elapsed_ms
|
||||
content = response.content
|
||||
|
||||
# 0 字节响应:请求未进入业务层
|
||||
if len(content) == 0:
|
||||
detail = (
|
||||
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
|
||||
+ ",响应体为 0 字节(空响应)。\n"
|
||||
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
|
||||
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
|
||||
"Accept-Encoding 是否被置空。\n"
|
||||
" 响应头:" + repr(dict(response.headers))
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail)
|
||||
|
||||
try:
|
||||
response_json = self._decrypt_response(content)
|
||||
except ValueError as exc:
|
||||
detail = (
|
||||
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
|
||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
||||
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail) from exc
|
||||
except zlib.error as exc:
|
||||
detail = (
|
||||
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
|
||||
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
|
||||
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
|
||||
"(可能是网关/防火墙返回的页面)。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail) from exc
|
||||
|
||||
if not response_json:
|
||||
detail = (
|
||||
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
|
||||
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
|
||||
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
|
||||
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
|
||||
)
|
||||
log_error(detail)
|
||||
raise MaimaiNetError(detail)
|
||||
|
||||
# ★ DEBUG 模式:记录「api 返回的数据」
|
||||
try:
|
||||
shown_body: Any = json.loads(response_json)
|
||||
except Exception:
|
||||
shown_body = response_json
|
||||
log_response(
|
||||
status=response.status_code,
|
||||
body=shown_body,
|
||||
headers={k: v for k, v in response.headers.items()},
|
||||
api_name=api_name,
|
||||
elapsed_ms=elapsed_ms,
|
||||
)
|
||||
|
||||
return response_json
|
||||
|
||||
def request_vo(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any,
|
||||
response_cls: Type[T],
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> T:
|
||||
"""发送请求并把响应映射成 dataclass。"""
|
||||
raw = self.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
return vo_from_json(response_cls, raw)
|
||||
|
||||
def request_dict(
|
||||
self,
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发送请求并返回**原始响应 dict**。
|
||||
|
||||
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
|
||||
映射成 dataclass 会丢掉服务端多给的字段。
|
||||
"""
|
||||
raw = self.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, dict) else {"_data": data}
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层会话。"""
|
||||
try:
|
||||
self.session.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "MaimaiNetClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 六、全局默认客户端
|
||||
# =============================================================================
|
||||
|
||||
_default_client: Optional[MaimaiNetClient] = None
|
||||
|
||||
|
||||
def get_default_client() -> MaimaiNetClient:
|
||||
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
|
||||
global _default_client
|
||||
if _default_client is None:
|
||||
_default_client = MaimaiNetClient()
|
||||
return _default_client
|
||||
|
||||
|
||||
def close_default_client() -> None:
|
||||
"""关闭全局默认客户端(一般不需要手动调用)。"""
|
||||
global _default_client
|
||||
if _default_client is not None:
|
||||
_default_client.close()
|
||||
_default_client = None
|
||||
|
||||
|
||||
def request(
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
|
||||
net = client or get_default_client()
|
||||
return net.request(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
def request_dict(
|
||||
api_name: str,
|
||||
request_vo: Any = None,
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
|
||||
net = client or get_default_client()
|
||||
return net.request_dict(
|
||||
api_name, request_vo,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
def request_vo(
|
||||
api_name: str,
|
||||
request_vo: Any,
|
||||
response_cls: Type[T],
|
||||
*,
|
||||
user_id: int = 0,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> T:
|
||||
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
|
||||
net = client or get_default_client()
|
||||
return net.request_vo(
|
||||
api_name, request_vo, response_cls,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms, extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 七、通用请求 / 响应 VO
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class EmptyRequestVO:
|
||||
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdRequestVO:
|
||||
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
|
||||
userId: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReturnCodeVO:
|
||||
"""通用「只有 returnCode」的响应体。"""
|
||||
returnCode: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 单独运行:协议自检(不发业务请求)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== maimai_net 协议自检 ===")
|
||||
print("BASE_URL :", BASE_URL)
|
||||
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
|
||||
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
|
||||
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
|
||||
|
||||
sample = '{"userId":12345678,"token":"abc"}'
|
||||
packed = deflate_compress(sample.encode("utf-8"))
|
||||
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
|
||||
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
|
||||
assert deflate_decompress(packed).decode("utf-8") == sample
|
||||
|
||||
enc = aes_encrypt(packed)
|
||||
assert aes_decrypt(enc) == packed
|
||||
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
|
||||
print("自检通过")
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Ping.py —— 心跳探活(PingMaimaiChn)
|
||||
================================================================================
|
||||
|
||||
功能:向游戏服务器发一次心跳,确认链路可达。
|
||||
|
||||
对应接口:PingMaimaiChn
|
||||
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
|
||||
响应 :{"result": "Pong"}
|
||||
|
||||
★ 接口名(容易写错,注意):
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
|
||||
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
|
||||
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
|
||||
所以混淆用的全名是 "PingMaimaiChn":
|
||||
md5("PingMaimaiChn" + "<REDACTED>") = 3bae8c1162b3148abcfc38a89c03e3e7
|
||||
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
|
||||
|
||||
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
|
||||
也是基于补完后缀的 "PingMaimaiChn"。)
|
||||
|
||||
★ 为什么要跑它(不只是探活):
|
||||
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
|
||||
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
|
||||
|
||||
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
|
||||
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
|
||||
与真机开机后第一屏(还没跑过心跳)的行为一致。
|
||||
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
|
||||
|
||||
调用示例:
|
||||
from api import Ping
|
||||
if Ping():
|
||||
print("服务器可达")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def Ping(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
心跳探活。
|
||||
|
||||
参数:
|
||||
client_id: 机台短值(可选)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
True = 服务器返回 "Pong"
|
||||
"""
|
||||
log_info("发送心跳探活")
|
||||
|
||||
raw = request_dict(
|
||||
"Ping", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
result = raw.get("result", "")
|
||||
ok = (result == "Pong")
|
||||
if ok:
|
||||
log_info("心跳正常:result=Pong")
|
||||
else:
|
||||
log_warning("心跳返回异常:result=" + str(result))
|
||||
return ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("ping_test")
|
||||
try:
|
||||
ok = Ping()
|
||||
print("")
|
||||
print("服务器可达:" + str(ok))
|
||||
return 0 if ok else 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UploadUserPlaylog —— 上传单局成绩原始记录(UploadUserPlaylogApi)
|
||||
|
||||
★ 为什么需要它:
|
||||
真机结算序列是**先 UploadUserPlaylog、后 UpsertUserAll**(2026-10-21 抓包 055/056 号):
|
||||
|
||||
055_UploadUserPlaylogApi request 2083 字节 -> returnCode=1
|
||||
056_UpsertUserAllApi request 21833 字节 -> returnCode=1
|
||||
|
||||
Astral 原来只发 UpsertUserAll,服务端就只回 returnCode=1 而不落库。
|
||||
这条前置是「本次会话确实打过这一局」的凭证。
|
||||
|
||||
请求体(源码 Net.VO.Mai2/UserPlaylogRequestVO.cs):
|
||||
{ "userId": <int>, "userPlaylog": { ...110 字段... } }
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
from .MaimaiNet import MaimaiNetClient, get_default_client, request_dict
|
||||
from .LogSetup import log_info
|
||||
|
||||
|
||||
def UploadUserPlaylog(
|
||||
user_id: int,
|
||||
user_playlog: Dict[str, Any],
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""上传一局成绩明细(无落库副作用,但服务端会记账,供 UpsertUserAll 消费)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID
|
||||
user_playlog: 110 字段成绩明细,用
|
||||
api.UpsertUserAll.build_user_playlog(playlog_id, login_date, play) 构造
|
||||
|
||||
返回:原始响应 dict({"returnCode": 1, "apiName": "UploadUserPlaylogApi"})
|
||||
"""
|
||||
body = {"userId": int(user_id), "userPlaylog": user_playlog}
|
||||
log_info("上传成绩明细:user_id=" + str(user_id)
|
||||
+ " musicId=" + str(user_playlog.get("musicId"))
|
||||
+ " level=" + str(user_playlog.get("level"))
|
||||
+ " achievement=" + str(user_playlog.get("achievement")))
|
||||
return request_dict(
|
||||
"UploadUserPlaylogApi", body,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS,
|
||||
client=client,
|
||||
)
|
||||
@@ -0,0 +1,620 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
||||
================================================================================
|
||||
|
||||
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
|
||||
|
||||
对应接口:UpsertUserChargelogApi
|
||||
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
|
||||
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
|
||||
public ulong userId;
|
||||
public UserChargelog userChargelog;
|
||||
public GetUserCharge userCharge;
|
||||
public long loginDateTime;
|
||||
响应 :{returnCode, apiName}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
一、真机的购买流程(源码逐行可查)
|
||||
--------------------------------------------------------------------------------
|
||||
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
|
||||
├─ ticket.ticketKind.id == 1(付费票)
|
||||
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
|
||||
│ (= GetGameChargeApi 的响应)
|
||||
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
|
||||
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
|
||||
│ └─ 没有 -> 新增一条 stock=1
|
||||
└─ TicketManager.cs:234 RequestUserCharge(playerId)
|
||||
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
|
||||
│ -> 取**购买后**(stock > 0)的那条 GetUserCharge
|
||||
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
|
||||
│ -> chargeId = ticketId
|
||||
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
|
||||
│ purchaseDate = TimeManager.GetNowDateString()
|
||||
│ playCount = 玩家总游玩次数
|
||||
│ playerRating = 玩家 Rating
|
||||
│ placeId / regionId / clientId = 当前机台
|
||||
└─ 发 UpsertUserChargelogApi
|
||||
|
||||
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
|
||||
因为那张票马上就被这一局用掉了。本模块只负责「买」,
|
||||
所以默认传的是**购买后**的库存(现有 + 1)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
二、票的有效期算法
|
||||
--------------------------------------------------------------------------------
|
||||
Manager/TicketManager.cs:564
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
|
||||
真机验证(source/2026-10-05.log:123 的 userChargeList):
|
||||
chargeId=5 purchaseDate="2026-10-04 18:22:10"
|
||||
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
|
||||
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
三、ticket.creditNum 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
游戏本地主数据:
|
||||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||||
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
四、安全闸门
|
||||
--------------------------------------------------------------------------------
|
||||
本接口会**真实写入一条购买记录**,所以默认不发包:
|
||||
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
|
||||
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
|
||||
UpsertUserChargelog(..., confirm=True) -> 真实发送
|
||||
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
五、调用示例
|
||||
--------------------------------------------------------------------------------
|
||||
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
|
||||
LogoutType, UpsertUserChargelog
|
||||
|
||||
setup_logging("买票")
|
||||
user_id, token = ParseQRCode(qr)
|
||||
login = UserLogin(user_id, token)
|
||||
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
||||
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import config
|
||||
from config import (
|
||||
CLIENT_ID,
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
TIMEOUT_MS,
|
||||
UPSERT_CHARGELOG_CONFIRM,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:游戏本地主数据
|
||||
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
|
||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
||||
#
|
||||
# ★ 为什么需要它:
|
||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
||||
# result.price = ticket.creditNum;
|
||||
#
|
||||
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
|
||||
#: chargeId -> creditNum
|
||||
TICKET_CREDIT_NUM = {
|
||||
0: 0,
|
||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
||||
11001: 0, # 免费 1.5 倍券
|
||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
||||
}
|
||||
|
||||
#: 票名(只用于日志好看,不影响协议)
|
||||
TICKET_NAMES = {
|
||||
0: "使用しない",
|
||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
||||
}
|
||||
|
||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
|
||||
#: 票有效期算法参数
|
||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
from .ChargeGuard import (
|
||||
has_fresh_read,
|
||||
last_read_age,
|
||||
note_charge_purchase,
|
||||
purchased_charge_ids,
|
||||
)
|
||||
from .LogSetup import log_debug, log_error, log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
from .UpsertUserAll import UpsertResponseVO, resolve_api_func
|
||||
|
||||
|
||||
class ChargelogConfirmError(RuntimeError):
|
||||
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
|
||||
|
||||
|
||||
class ChargelogGuardError(RuntimeError):
|
||||
"""
|
||||
安全检查未通过(此时**未发包**)。
|
||||
|
||||
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
|
||||
|
||||
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
|
||||
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
|
||||
所以正常状态下 `userChargeList` 里的 stock 全是 0。
|
||||
|
||||
如果账号**已经持有**一张功能票还去发购买请求,
|
||||
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
|
||||
所以本模块默认硬拦。
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class UserChargeVO:
|
||||
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
|
||||
chargeId: int = 0
|
||||
stock: int = 0
|
||||
purchaseDate: str = ""
|
||||
validDate: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserChargelogVO:
|
||||
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
|
||||
chargeId: int = 0
|
||||
price: int = 0
|
||||
purchaseDate: str = ""
|
||||
playCount: int = 0
|
||||
playerRating: int = 0
|
||||
placeId: int = 0
|
||||
regionId: int = 0
|
||||
clientId: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserChargelogRequestVO:
|
||||
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
|
||||
userId: int
|
||||
userChargelog: UserChargelogVO
|
||||
userCharge: UserChargeVO
|
||||
loginDateTime: int = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、工具
|
||||
# =============================================================================
|
||||
|
||||
def ticket_name(charge_id: int) -> str:
|
||||
"""取票名(只用于日志)。"""
|
||||
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
|
||||
|
||||
|
||||
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
||||
"""
|
||||
取 userChargelog.price —— 即 ticket.creditNum。
|
||||
|
||||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
||||
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
"""
|
||||
if override is not None:
|
||||
return int(override)
|
||||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
||||
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
+ " —— 按 0 处理,建议补进配置")
|
||||
return 0
|
||||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
||||
|
||||
|
||||
#: 北京时间(CST = UTC+8)
|
||||
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_cst(now: Optional[datetime] = None) -> datetime:
|
||||
"""
|
||||
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
|
||||
|
||||
★ 为什么要显式 +8 而不是 datetime.now():
|
||||
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
|
||||
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
|
||||
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
|
||||
"""
|
||||
if now is not None:
|
||||
return now
|
||||
return datetime.now(timezone.utc).astimezone(CST)
|
||||
|
||||
|
||||
def _now_string(now: Optional[datetime] = None) -> str:
|
||||
"""
|
||||
TimeManager.GetNowDateString() 的等价物。
|
||||
|
||||
依据 Manager/TimeManager.cs:7,62-70:
|
||||
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
|
||||
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
|
||||
|
||||
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
|
||||
"purchaseDate":"2026-10-06 13:20:10.0"
|
||||
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
|
||||
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
|
||||
"""
|
||||
d = now_cst(now)
|
||||
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
|
||||
|
||||
|
||||
def calc_valid_date(
|
||||
now: Optional[datetime] = None,
|
||||
*,
|
||||
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
|
||||
days: int = TICKET_VALID_DAYS,
|
||||
) -> str:
|
||||
"""
|
||||
算票的有效期字符串。
|
||||
|
||||
依据 TicketManager.cs:562-565:
|
||||
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
|
||||
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
|
||||
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
|
||||
"""
|
||||
base = now_cst(now)
|
||||
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
|
||||
return d.strftime("%Y-%m-%d") + " 04:00:00"
|
||||
|
||||
|
||||
def parse_wall_time(text: Any) -> Optional[datetime]:
|
||||
"""
|
||||
解析墙上时间串,兼容两种格式:
|
||||
|
||||
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
|
||||
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
|
||||
|
||||
解析不出来返回 None。
|
||||
"""
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
|
||||
if not isinstance(charge_list, list):
|
||||
return None
|
||||
for c in charge_list:
|
||||
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、主函数
|
||||
# =============================================================================
|
||||
|
||||
def UpsertUserChargelog(
|
||||
user_id: int,
|
||||
charge_id: int,
|
||||
login_date_time: int,
|
||||
*,
|
||||
charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||
game_charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||
user_data: Optional[Dict[str, Any]] = None,
|
||||
stock: Optional[int] = None,
|
||||
price: Optional[int] = None,
|
||||
purchase_date: Optional[str] = None,
|
||||
valid_date: Optional[str] = None,
|
||||
play_count: Optional[int] = None,
|
||||
player_rating: Optional[int] = None,
|
||||
place_id: int = PLACE_ID,
|
||||
region_id: int = REGION_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
guard_no_stock: bool = True,
|
||||
guard_purchasable: bool = True,
|
||||
guard_require_charge_read: bool = True,
|
||||
guard_single_purchase: bool = True,
|
||||
confirm: Optional[bool] = None,
|
||||
dry_run: bool = False,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
|
||||
"""
|
||||
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
||||
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
|
||||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
||||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
||||
用来算「购买后库存」和是否已有该票。
|
||||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
||||
用来填 playCount / playerRating。
|
||||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
||||
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
|
||||
purchase_date: 购买时刻字符串;不传 = 现在
|
||||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
||||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
||||
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
|
||||
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
|
||||
(不在清单里就抛 ChargelogGuardError,未发包)。
|
||||
依据 TicketManager.cs:137-150 —— 真机也是先在
|
||||
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
|
||||
找不到直接 return false。服务端对未上架的票会 returnCode=0。
|
||||
实测:本机台只上架 chargeId=2/3,买 5 会失败。
|
||||
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
|
||||
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
|
||||
(抛 ChargelogGuardError,未发包)。
|
||||
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
|
||||
确认要强行购买才传 False。
|
||||
guard_require_charge_read:
|
||||
★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge**
|
||||
(本进程内、10 分钟内),否则直接拒绝购买。
|
||||
目的:防止「没查票就买票」造成同一账号重复购买。
|
||||
显式传了 charge_list 也算「查过了」。
|
||||
实现见 api/ChargeGuard.py。
|
||||
guard_single_purchase:
|
||||
★ 默认 True = **同一账号在同一进程里只允许买一次票**。
|
||||
真机买完票会立刻被本局用掉(stock 恒为 0),
|
||||
连着买两张属于异常行为。实现见 api/ChargeGuard.py。
|
||||
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
|
||||
dry_run: True = 只构造并返回报文,绝不发包
|
||||
|
||||
返回:
|
||||
dry_run=True -> UserChargelogRequestVO
|
||||
dry_run=False -> UpsertResponseVO
|
||||
"""
|
||||
if not login_date_time:
|
||||
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
|
||||
"(PacketUploadUserChargelog.cs:23)")
|
||||
|
||||
name = ticket_name(charge_id)
|
||||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
||||
+ "(" + name + ")dry_run=" + str(dry_run))
|
||||
|
||||
if int(charge_id) not in tuple(PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
|
||||
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
|
||||
# ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ----------
|
||||
# ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会
|
||||
# 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。
|
||||
if guard_require_charge_read and charge_list is None:
|
||||
if not has_fresh_read(user_id):
|
||||
msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n"
|
||||
" 买票前必须先查一次账号当前票券(有没有持票),否则无法排除"
|
||||
"「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n"
|
||||
" 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n"
|
||||
" 或者直接把查到的票券列表传进来:charge_list=[...]\n"
|
||||
" 见 api/ChargeGuard.py。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
log_info("前置检查通过:本次购买前已调用 GetUserCharge("
|
||||
+ str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)")
|
||||
|
||||
# ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ----------
|
||||
if guard_single_purchase:
|
||||
done = purchased_charge_ids(user_id)
|
||||
if done:
|
||||
msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id)
|
||||
+ ")**已经买过功能票**(chargeId=" + str(done) + ")。\n"
|
||||
" 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;"
|
||||
"连着买两张属于异常行为,有**封号风险**。\n"
|
||||
" 确需强行购买请传 guard_single_purchase=False。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
|
||||
# ---------- 1. 取当前状态 ----------
|
||||
if charge_list is None:
|
||||
log_info("未传 charge_list,自动抓取当前票券 ...")
|
||||
raw = resolve_api_func("GetUserCharge")(
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||
|
||||
if user_data is None:
|
||||
log_info("未传 user_data,自动抓取当前档案 ...")
|
||||
raw_ud = resolve_api_func("GetUserData")(
|
||||
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
|
||||
|
||||
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
|
||||
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
|
||||
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
|
||||
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
|
||||
if guard_purchasable:
|
||||
if game_charge_list is None:
|
||||
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
|
||||
_gc = resolve_api_func("GetGameCharge")(
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
game_charge_list = (_gc.get("gameChargeList")
|
||||
if isinstance(_gc, dict) else None) or []
|
||||
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
|
||||
if isinstance(g, dict) and g.get("chargeId") is not None]
|
||||
log_info("当前可购买 chargeId:" + str(avail))
|
||||
if int(charge_id) not in avail:
|
||||
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
|
||||
+ ")**不在当前可购买清单里**。\n"
|
||||
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
|
||||
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
|
||||
" 列出来的票,找不到就不发购买请求。\n"
|
||||
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
|
||||
" 确需强行购买请传 guard_purchasable=False。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
|
||||
|
||||
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
|
||||
held = [c for c in (charge_list or [])
|
||||
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||
if held:
|
||||
desc = "、".join(
|
||||
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
|
||||
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
|
||||
for c in held)
|
||||
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
|
||||
if guard_no_stock:
|
||||
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
|
||||
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
|
||||
"(stock--),正常状态 stock 恒为 0。\n"
|
||||
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
|
||||
" 确需强行购买请传 guard_no_stock=False。")
|
||||
log_error(msg)
|
||||
raise ChargelogGuardError(msg)
|
||||
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
|
||||
else:
|
||||
log_info("安全检查通过:账号当前没有持有任何功能票")
|
||||
|
||||
existing = _find_charge(charge_list, charge_id)
|
||||
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
|
||||
if stock is None:
|
||||
stock = old_stock + 1
|
||||
stock = int(stock)
|
||||
|
||||
if price is None:
|
||||
price = ticket_price(charge_id)
|
||||
price = int(price)
|
||||
|
||||
if play_count is None:
|
||||
play_count = int(user_data.get("playCount", 0) or 0)
|
||||
if player_rating is None:
|
||||
player_rating = int(user_data.get("playerRating", 0) or 0)
|
||||
|
||||
now = now_cst()
|
||||
if not purchase_date:
|
||||
purchase_date = _now_string(now)
|
||||
if not valid_date:
|
||||
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
|
||||
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
|
||||
# 有效期就该从**那个时刻**推,而不是从现在推。
|
||||
base = parse_wall_time(purchase_date)
|
||||
if base is None:
|
||||
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
|
||||
"validDate 改按「现在」计算:" + str(purchase_date))
|
||||
base = now
|
||||
valid_date = calc_valid_date(base)
|
||||
|
||||
log_info(" 票名 : " + name)
|
||||
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
|
||||
log_info(" price : " + str(price) + "(= ticket.creditNum)")
|
||||
log_info(" purchaseDate: " + purchase_date)
|
||||
log_info(" validDate : " + valid_date)
|
||||
|
||||
# ---------- 2. 构造请求 ----------
|
||||
req = UserChargelogRequestVO(
|
||||
userId=int(user_id),
|
||||
userChargelog=UserChargelogVO(
|
||||
chargeId=int(charge_id),
|
||||
price=price,
|
||||
purchaseDate=purchase_date,
|
||||
playCount=int(play_count),
|
||||
playerRating=int(player_rating),
|
||||
placeId=int(place_id),
|
||||
regionId=int(region_id),
|
||||
clientId=client_id,
|
||||
),
|
||||
userCharge=UserChargeVO(
|
||||
chargeId=int(charge_id),
|
||||
stock=stock,
|
||||
purchaseDate=purchase_date,
|
||||
validDate=valid_date,
|
||||
),
|
||||
loginDateTime=int(login_date_time),
|
||||
)
|
||||
|
||||
# ---------- 3. dry-run ----------
|
||||
if dry_run:
|
||||
log_info("[dry-run] 已构造购买报文,未发包")
|
||||
log_debug("dry-run 报文:" + __import__("json").dumps(
|
||||
req, ensure_ascii=False, default=str))
|
||||
return req
|
||||
|
||||
# ---------- 4. 副作用闸门 ----------
|
||||
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
|
||||
if not effective:
|
||||
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
|
||||
"本脚本默认不放行。确认要发请传 confirm=True,"
|
||||
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
|
||||
"只想看报文请传 dry_run=True。")
|
||||
log_error(msg)
|
||||
raise ChargelogConfirmError(msg)
|
||||
|
||||
# ---------- 5. 发包 ----------
|
||||
log_info("闸门已放行,开始上传购买记录 ...")
|
||||
raw = request_dict(
|
||||
"UpsertUserChargelogApi", req,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
resp = UpsertResponseVO(
|
||||
returnCode=int(raw.get("returnCode", 0) or 0),
|
||||
apiName=str(raw.get("apiName", "") or ""),
|
||||
)
|
||||
if resp.returnCode != 1:
|
||||
msg = ("购买失败:returnCode=" + str(resp.returnCode)
|
||||
+ " apiName=" + resp.apiName)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
|
||||
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
|
||||
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
|
||||
note_charge_purchase(user_id, charge_id)
|
||||
return resp
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、单独运行(调试)
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chargelog_test")
|
||||
try:
|
||||
log_info("构造一张 2 倍券的购买报文(不发包)")
|
||||
req = UpsertUserChargelog(
|
||||
12345678, 2, 1791159576,
|
||||
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
|
||||
dry_run=True,
|
||||
)
|
||||
log_info("userChargelog = " + str(req.userChargelog))
|
||||
log_info("userCharge = " + str(req.userCharge))
|
||||
log_info("validDate 算法自检:")
|
||||
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
|
||||
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
|
||||
+ "(真机是 2027-01-02 04:00:00)")
|
||||
return 0
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UserGetterCommon.py —— 各取数脚本的公共底座
|
||||
================================================================================
|
||||
|
||||
只提供三样东西,取数逻辑都在各自的脚本里:
|
||||
|
||||
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
|
||||
2. _get() 发一次请求并返回**原始响应 dict**
|
||||
3. get_all_pages() 自动翻页
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么单独抽出来:各接口的请求体并不统一
|
||||
--------------------------------------------------------------------------------
|
||||
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
|
||||
|
||||
GameKaleidxScopeRequestVO : 空类,一个字段都没有
|
||||
UserDetailRequestVO : 只有 userId
|
||||
UserMapRequestVO : userId + nextIndex + maxCount = 1000
|
||||
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
|
||||
UserItemRequestVO : userId + nextIndex + maxCount = 100
|
||||
UserMusicRequestVO : userId + nextIndex + maxCount = 50
|
||||
UserFavoriteRequestVO : userId + itemKind
|
||||
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
|
||||
UserShopStockRequestVO : userId + shopItemIdList
|
||||
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
|
||||
|
||||
发错字段名或漏字段,服务端会返回异常或空数据。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_debug, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, get_default_client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class EmptyRequestVO:
|
||||
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdRequestVO:
|
||||
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
|
||||
userId: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserIdPageRequestVO:
|
||||
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 1000
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginBonusRequestVO:
|
||||
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserItemRequestVO:
|
||||
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMusicRequestVO:
|
||||
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserCourseRequestVO:
|
||||
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
|
||||
userId: int
|
||||
nextIndex: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFavoriteRequestVO:
|
||||
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
|
||||
userId: int
|
||||
itemKind: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFavoriteItemRequestVO:
|
||||
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
|
||||
userId: int
|
||||
kind: int = 1
|
||||
nextIndex: int = 0
|
||||
maxCount: int = 100
|
||||
isAllFavoriteItem: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserShopStockRequestVO:
|
||||
"""对应 UserShopStockRequestVO。"""
|
||||
userId: int
|
||||
shopItemIdList: List[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRivalRequestVO:
|
||||
"""对应 UserRivalRequestVO。"""
|
||||
userId: int
|
||||
rivalId: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserRivalMusicRequestVO:
|
||||
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
|
||||
userId: int
|
||||
rivalId: int = 0
|
||||
nextIndex: int = 0
|
||||
userRivalMusicLevelList: List[Dict[str, int]] = field(
|
||||
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 常量
|
||||
# =============================================================================
|
||||
|
||||
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
|
||||
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
|
||||
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
|
||||
|
||||
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
|
||||
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
|
||||
#: Create(GetNetQuery(userId, itemId * 10000000000L));
|
||||
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
|
||||
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
|
||||
|
||||
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
|
||||
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
|
||||
|
||||
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
|
||||
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 内部工具
|
||||
# =============================================================================
|
||||
|
||||
def _get(
|
||||
api_name: str,
|
||||
body: Any,
|
||||
user_id: int,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
|
||||
|
||||
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
|
||||
映射成 dataclass 会丢掉服务端多给的字段。
|
||||
"""
|
||||
net = client or get_default_client()
|
||||
raw = net.request(
|
||||
api_name, body,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS,
|
||||
)
|
||||
if isinstance(raw, str):
|
||||
return json.loads(raw) if raw else {}
|
||||
return raw or {}
|
||||
|
||||
|
||||
def get_all_pages(
|
||||
api_name: str,
|
||||
user_id: int,
|
||||
list_key: str,
|
||||
*,
|
||||
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
max_pages: int = 200,
|
||||
) -> List[Any]:
|
||||
"""
|
||||
自动翻页抓取某个列表接口的全部条目。
|
||||
|
||||
分页约定(各 Packet 的 Proc 里都是这个逻辑):
|
||||
nextIndex == 0 -> 没有更多数据
|
||||
nextIndex != 0 -> 下一页的起始索引
|
||||
|
||||
参数:
|
||||
api_name: 逻辑接口名
|
||||
list_key: 响应里承载列表的键名(如 "userMapList")
|
||||
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
|
||||
★ 首页会以 next_index=None 调用它 —— 这是为了支持
|
||||
GetUserItemApi 这类「首发要带哨兵值」的接口
|
||||
(首包 nextIndex = itemKind * 1e10,不能是 0)。
|
||||
max_pages: 翻页上限(防御性)
|
||||
|
||||
返回:
|
||||
合并后的全部条目
|
||||
"""
|
||||
all_items: List[Any] = []
|
||||
next_index: Optional[int] = None
|
||||
for page in range(max_pages):
|
||||
if body_factory is not None:
|
||||
body = body_factory(next_index)
|
||||
else:
|
||||
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
|
||||
|
||||
resp = _get(api_name, body, user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
items = resp.get(list_key) or []
|
||||
if isinstance(items, dict):
|
||||
items = [items]
|
||||
all_items.extend(items)
|
||||
|
||||
next_index = int(resp.get("nextIndex") or 0)
|
||||
log_debug(
|
||||
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
|
||||
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
|
||||
)
|
||||
if not next_index:
|
||||
break
|
||||
else:
|
||||
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
|
||||
|
||||
return all_items
|
||||
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UserLogin.py —— 用户正式登录(UserLoginApi)
|
||||
================================================================================
|
||||
|
||||
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
|
||||
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
|
||||
|
||||
对应接口:UserLoginApi
|
||||
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
|
||||
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
|
||||
响应 :UserLoginResponseVO(returnCode=1 表示成功)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 关于 loginDateTime(与后续接口强相关,务必理解)
|
||||
--------------------------------------------------------------------------------
|
||||
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
|
||||
|
||||
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
|
||||
|
||||
东八区即 now + 28800。
|
||||
|
||||
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
|
||||
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
|
||||
|
||||
影响:PacketUserLogin.cs:43 会把请求值回填响应;
|
||||
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
|
||||
与登录时不一致会被服务端拒绝。
|
||||
|
||||
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
|
||||
|
||||
★ 关于 dateTime 与 loginDateTime 的区别
|
||||
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
|
||||
loginDateTime= 本次登录时刻(上面的特殊算法)
|
||||
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
|
||||
|
||||
调用示例:
|
||||
from api import UserLogin
|
||||
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
|
||||
print(login.loginId, login.loginDateTime)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
|
||||
|
||||
from .LogSetup import log_debug, log_error, log_info
|
||||
from .MaimaiNet import (
|
||||
MaimaiNetClient,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
request_dict,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginRequestVO:
|
||||
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
dateTime: int = 0
|
||||
loginDateTime: int = 0
|
||||
isContinue: bool = False
|
||||
genericFlag: int = 0
|
||||
token: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLoginResponseVO:
|
||||
"""
|
||||
响应 VO(对应 UserLoginResponseVO)。
|
||||
|
||||
★ 后两个字段是**客户端回填**的,服务端不返回:
|
||||
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
|
||||
UpsertUserAll / UserLogout 都复用它
|
||||
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
|
||||
UpsertUserAll 的 userData.dateTime 要用它
|
||||
(真机 source/2026-10-05.log:123 两处都是 1791134329)
|
||||
"""
|
||||
returnCode: int = 0
|
||||
lastLoginDate: str = ""
|
||||
loginCount: int = 0
|
||||
consecutiveLoginCount: int = 0
|
||||
loginId: int = 0
|
||||
loginDateTime: int = 0
|
||||
token: str = ""
|
||||
dateTime: int = 0
|
||||
|
||||
|
||||
def UserLogin(
|
||||
user_id: int,
|
||||
token: str,
|
||||
*,
|
||||
access_code: str = "",
|
||||
region_id: int = REGION_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
is_continue: bool = False,
|
||||
generic_flag: int = 0,
|
||||
date_time: Optional[int] = None,
|
||||
login_date_time: Optional[int] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserLoginResponseVO:
|
||||
"""
|
||||
用户正式登录(创建登录会话)。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填,来自 ParseQRCode)
|
||||
access_code: 访问码(可选,通常为空)
|
||||
region_id: 区域 ID(可选,默认 config.REGION_ID)
|
||||
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
|
||||
client_id: 机台短值(可选)
|
||||
is_continue: 是否续玩(可选)
|
||||
generic_flag: 通用标志位(可选)
|
||||
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
|
||||
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
|
||||
timeout_ms: 超时毫秒(可选,默认 60 秒)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserLoginResponseVO(loginDateTime 已按真机行为回填)
|
||||
|
||||
异常:
|
||||
RuntimeError: returnCode != 1
|
||||
"""
|
||||
log_info(
|
||||
"用户正式登录:user_id=" + str(user_id)
|
||||
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
if date_time is None:
|
||||
date_time = now
|
||||
if login_date_time is None:
|
||||
login_date_time = login_date_time_now()
|
||||
|
||||
log_debug(
|
||||
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
|
||||
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
|
||||
)
|
||||
|
||||
raw = request_dict(
|
||||
"UserLoginApi",
|
||||
UserLoginRequestVO(
|
||||
userId=user_id,
|
||||
accessCode=access_code,
|
||||
regionId=region_id,
|
||||
placeId=place_id,
|
||||
clientId=client_id,
|
||||
dateTime=date_time,
|
||||
loginDateTime=login_date_time,
|
||||
isContinue=is_continue,
|
||||
genericFlag=generic_flag,
|
||||
token=token,
|
||||
),
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
|
||||
)
|
||||
|
||||
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
|
||||
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
|
||||
|
||||
if resp.returnCode != 1:
|
||||
msg = "登录失败:returnCode=" + str(resp.returnCode)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
|
||||
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
|
||||
if not resp.loginDateTime:
|
||||
resp.loginDateTime = login_date_time
|
||||
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
|
||||
|
||||
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
|
||||
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
|
||||
resp.dateTime = date_time
|
||||
|
||||
log_info(
|
||||
"用户登录成功:loginId=" + str(resp.loginId)
|
||||
+ " loginDateTime=" + str(resp.loginDateTime)
|
||||
+ " dateTime=" + str(resp.dateTime)
|
||||
+ " lastLoginDate=" + resp.lastLoginDate
|
||||
+ " loginCount=" + str(resp.loginCount)
|
||||
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogin_test")
|
||||
try:
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
|
||||
return 1
|
||||
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
|
||||
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
|
||||
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
|
||||
print("")
|
||||
for name in UserLoginResponseVO.__dataclass_fields__:
|
||||
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("用户登录失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,254 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
UserLogout.py —— 用户登出(UserLogoutApi)
|
||||
================================================================================
|
||||
|
||||
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
|
||||
|
||||
对应接口:UserLogoutApi
|
||||
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
|
||||
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
|
||||
其他 type -> UserLogoutRequestVO (不含 delayLog)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
|
||||
--------------------------------------------------------------------------------
|
||||
None = 0
|
||||
Logout = 1 正常登出(带 delayLog 统计)
|
||||
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
|
||||
Error = 3 错误强制登出(GameMain.cs:91)
|
||||
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
|
||||
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
|
||||
|
||||
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
|
||||
|
||||
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
|
||||
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
|
||||
每个元素 {count, size, msec, retry}。
|
||||
|
||||
调用示例:
|
||||
from api import UserLogout, LogoutType
|
||||
UserLogout(12345678, token, login.loginDateTime,
|
||||
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .LogSetup import log_debug, log_error, log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
#: DelayLog.request 的定长(DelayLog.cs:17)
|
||||
DELAY_LOG_SLOTS = 26
|
||||
|
||||
|
||||
class LogoutType:
|
||||
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
|
||||
None_ = 0
|
||||
Logout = 1 # 正常登出(带 delayLog)
|
||||
Cancel = 2 # 取消 / 强制登出
|
||||
Error = 3 # 错误强制登出
|
||||
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
|
||||
Quit = 5 # 退出强制登出
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestAPI:
|
||||
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
|
||||
count: int = 0
|
||||
size: int = 0
|
||||
msec: int = 0
|
||||
retry: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DelayLog:
|
||||
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
|
||||
dlRequests: int = 0
|
||||
dlSize: int = 0
|
||||
dlRetry: int = 0
|
||||
loginMsec: int = 0
|
||||
saveMsec: int = 0
|
||||
reductionMusic: int = 0
|
||||
reductionItem: int = 0
|
||||
request: List[RequestAPI] = field(
|
||||
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutRequestVO:
|
||||
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
loginDateTime: int = 0
|
||||
type: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutRequest2VO:
|
||||
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
|
||||
userId: int
|
||||
accessCode: str = ""
|
||||
regionId: int = 0
|
||||
placeId: int = 0
|
||||
clientId: str = ""
|
||||
loginDateTime: int = 0
|
||||
type: int = 0
|
||||
delayLog: Optional[DelayLog] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserLogoutResponseVO:
|
||||
"""响应 VO(对应 UserLogoutResponseVO)。"""
|
||||
returnCode: int = 0
|
||||
|
||||
|
||||
def create_empty_delay_log() -> DelayLog:
|
||||
"""创建全零的 DelayLog(26 个槽位)。"""
|
||||
return DelayLog()
|
||||
|
||||
|
||||
def create_delay_log_from_stats(
|
||||
requests_stats: Optional[List[Dict[str, Any]]] = None,
|
||||
*,
|
||||
dl_requests: int = 0,
|
||||
dl_size: int = 0,
|
||||
dl_retry: int = 0,
|
||||
login_msec: int = 0,
|
||||
save_msec: int = 0,
|
||||
reduction_music: int = 0,
|
||||
reduction_item: int = 0,
|
||||
) -> DelayLog:
|
||||
"""
|
||||
用统计数据构造 DelayLog。
|
||||
|
||||
参数:
|
||||
requests_stats: 每个接口的统计列表,元素形如
|
||||
{"count":1,"size":2090,"msec":73,"retry":0}
|
||||
按 DelayLog.request 的槽位顺序填入(最多 26 个)
|
||||
"""
|
||||
dl = DelayLog(
|
||||
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
|
||||
loginMsec=login_msec, saveMsec=save_msec,
|
||||
reductionMusic=reduction_music, reductionItem=reduction_item,
|
||||
)
|
||||
for i, stat in enumerate(requests_stats or []):
|
||||
if i >= DELAY_LOG_SLOTS:
|
||||
break
|
||||
dl.request[i] = RequestAPI(
|
||||
count=int(stat.get("count", 0)),
|
||||
size=int(stat.get("size", 0)),
|
||||
msec=int(stat.get("msec", 0)),
|
||||
retry=int(stat.get("retry", 0)),
|
||||
)
|
||||
return dl
|
||||
|
||||
|
||||
def UserLogout(
|
||||
user_id: int,
|
||||
token: str,
|
||||
login_date_time: int,
|
||||
*,
|
||||
logout_type: int = LogoutType.TestIn,
|
||||
access_code: str = "",
|
||||
region_id: int = REGION_ID,
|
||||
place_id: int = PLACE_ID,
|
||||
client_id: str = CLIENT_ID,
|
||||
delay_log: Optional[DelayLog] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> UserLogoutResponseVO:
|
||||
"""
|
||||
用户登出。
|
||||
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
token: 会话令牌(必填)
|
||||
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
|
||||
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
|
||||
access_code: 访问码(可选)
|
||||
region_id: 区域 ID(可选)
|
||||
place_id: 店铺 ID(可选)
|
||||
client_id: 机台短值(可选)
|
||||
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
|
||||
timeout_ms: 超时毫秒(可选)
|
||||
client: 可复用的 MaimaiNetClient(可选)
|
||||
|
||||
返回:
|
||||
UserLogoutResponseVO
|
||||
|
||||
异常:
|
||||
RuntimeError: returnCode != 1
|
||||
"""
|
||||
log_info(
|
||||
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
|
||||
+ " loginDateTime=" + str(login_date_time)
|
||||
)
|
||||
|
||||
if logout_type == LogoutType.Logout:
|
||||
body: Any = UserLogoutRequest2VO(
|
||||
userId=user_id, accessCode=access_code,
|
||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
||||
loginDateTime=login_date_time, type=logout_type,
|
||||
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
|
||||
)
|
||||
log_debug("使用完整登出报文(含 delayLog)")
|
||||
else:
|
||||
body = UserLogoutRequestVO(
|
||||
userId=user_id, accessCode=access_code,
|
||||
regionId=region_id, placeId=place_id, clientId=client_id,
|
||||
loginDateTime=login_date_time, type=logout_type,
|
||||
)
|
||||
log_debug("使用简单登出报文(不含 delayLog)")
|
||||
|
||||
raw = request_dict(
|
||||
"UserLogoutApi", body,
|
||||
user_id=user_id, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
|
||||
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
|
||||
if resp.returnCode != 1:
|
||||
msg = "登出失败:returnCode=" + str(resp.returnCode)
|
||||
log_error(msg)
|
||||
raise RuntimeError(msg)
|
||||
|
||||
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
|
||||
return resp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogout_test")
|
||||
try:
|
||||
if len(sys.argv) < 4:
|
||||
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
|
||||
"[logout_type] [region_id] [place_id]")
|
||||
return 1
|
||||
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
|
||||
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
|
||||
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
|
||||
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
|
||||
logout_type=lt, region_id=rid, place_id=pid)
|
||||
print("")
|
||||
print("returnCode =", resp.returnCode)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log_error("用户登出失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
teardown_logging()
|
||||
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,563 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
api 包 —— 统一导出
|
||||
================================================================================
|
||||
|
||||
主脚本只需要 from api import ... 就能拿到全部功能。
|
||||
|
||||
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
|
||||
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
|
||||
|
||||
=== ParseQRCode 开始运行 ===
|
||||
[接口说明] 解析二维码并换取 userId 与 token。
|
||||
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
|
||||
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
|
||||
=== ParseQRCode 运行结束 ===
|
||||
|
||||
主脚本**不需要**写装饰器,也不需要自己埋点。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
目录结构(一个 api 一个脚本)
|
||||
--------------------------------------------------------------------------------
|
||||
基础设施
|
||||
LogSetup.py 统一日志(INFO / DEBUG 双模式)
|
||||
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
|
||||
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
|
||||
|
||||
会话链路
|
||||
Chime.py 扫码登录 -> userId + token
|
||||
GetGameSettings.py GetGameSettingApi(游戏设置)
|
||||
Ping.py 心跳探活
|
||||
GetUserPreview.py GetUserPreviewApi(用户预览)
|
||||
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
|
||||
UserLogout.py UserLogoutApi(登出)
|
||||
|
||||
上传 / 结算(有副作用的接口)
|
||||
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
|
||||
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
|
||||
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
|
||||
并把结果落盘到 <工程根>/data/<userId>.json
|
||||
|
||||
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
|
||||
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
|
||||
|
||||
取数 —— GetUser*(一个接口一个脚本)
|
||||
GetUserData.py GetUserDataApi
|
||||
GetUserExtend.py GetUserExtendApi
|
||||
GetUserOption.py GetUserOptionApi
|
||||
GetUserCharacter.py GetUserCharacterApi
|
||||
GetUserGhost.py GetUserGhostApi
|
||||
GetUserMap.py GetUserMapApi
|
||||
GetUserLoginBonus.py GetUserLoginBonusApi
|
||||
GetUserRating.py GetUserRatingApi
|
||||
GetUserItem.py GetUserItemApi
|
||||
GetUserMusic.py GetUserMusicApi
|
||||
GetUserCourse.py GetUserCourseApi
|
||||
GetUserCharge.py GetUserChargeApi
|
||||
GetUserFavorite.py GetUserFavoriteApi
|
||||
GetUserActivity.py GetUserActivityApi
|
||||
GetUserMission.py GetUserMissionDataApi
|
||||
GetUserIntimate.py GetUserIntimateApi
|
||||
GetUserShopStock.py GetUserShopStockApi
|
||||
GetUserFavoriteItem.py GetUserFavoriteItemApi
|
||||
GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
|
||||
GetUserFriendBonus.py GetUserFriendBonusApi
|
||||
GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
|
||||
GetUserRegion.py GetUserRegionApi
|
||||
GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
|
||||
GetUserNewItem.py GetUserNewItemListApi
|
||||
|
||||
取数 —— GetGame*
|
||||
GetGameSettings.py GetGameSettingApi
|
||||
GetGameCharge.py GetGameChargeApi
|
||||
"""
|
||||
|
||||
from .LogSetup import (
|
||||
# 会话
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
# 自动跟踪
|
||||
autowrap,
|
||||
get_called_apis,
|
||||
# 模式
|
||||
get_level,
|
||||
set_level,
|
||||
is_debug,
|
||||
LEVEL_INFO,
|
||||
LEVEL_DEBUG,
|
||||
# 装饰器 / 兼容
|
||||
api_log,
|
||||
get_logger,
|
||||
# 流程日志(INFO 可见)
|
||||
log_info,
|
||||
log_step,
|
||||
log_result,
|
||||
log_warning,
|
||||
log_error,
|
||||
log_section,
|
||||
log_field,
|
||||
log_debug,
|
||||
# 收发数据日志(DEBUG 专属)
|
||||
log_request,
|
||||
log_response,
|
||||
log_payload,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) 网络核心
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .MaimaiNet import (
|
||||
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
|
||||
BASE_URL,
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
OBFUSCATE_PARAM,
|
||||
MaimaiNetClient,
|
||||
MaimaiNetError,
|
||||
get_default_client,
|
||||
close_default_client,
|
||||
request,
|
||||
request_dict,
|
||||
request_vo,
|
||||
obfuscate_api_name,
|
||||
build_api_url,
|
||||
build_user_agent,
|
||||
adler32,
|
||||
aes_encrypt,
|
||||
aes_decrypt,
|
||||
deflate_compress,
|
||||
deflate_decompress,
|
||||
vo_to_json,
|
||||
vo_from_json,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
EmptyRequestVO,
|
||||
UserIdRequestVO,
|
||||
ReturnCodeVO,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) 会话链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .Chime import (
|
||||
ParseQRCode,
|
||||
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PORT,
|
||||
AIMEDB_PATH,
|
||||
QR_SIGN_KEY,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
|
||||
from .GetGameSettings import (
|
||||
GetGameSettings,
|
||||
GameSetting,
|
||||
GameSettingRequestVO,
|
||||
GameSettingResponseVO,
|
||||
is_maintenance,
|
||||
is_ping_disabled,
|
||||
)
|
||||
|
||||
from .Ping import Ping
|
||||
|
||||
from .GetUserPreview import (
|
||||
GetUserPreview,
|
||||
UserPreviewRequestVO,
|
||||
UserPreviewResponseVO,
|
||||
)
|
||||
|
||||
from .UserLogin import (
|
||||
UserLogin,
|
||||
UserLoginRequestVO,
|
||||
UserLoginResponseVO,
|
||||
)
|
||||
|
||||
from .UserLogout import (
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
DelayLog,
|
||||
RequestAPI,
|
||||
UserLogoutRequestVO,
|
||||
UserLogoutRequest2VO,
|
||||
UserLogoutResponseVO,
|
||||
create_empty_delay_log,
|
||||
create_delay_log_from_stats,
|
||||
DELAY_LOG_SLOTS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) 上传成绩
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .UpsertUserAll import (
|
||||
UpsertUserAll,
|
||||
PlayData,
|
||||
build_play_from_config,
|
||||
derive_play_characters,
|
||||
derive_play_rating,
|
||||
derive_play_grade,
|
||||
find_music_detail,
|
||||
PLAY_SPECIAL_NUM,
|
||||
PLAY_LOCAL_PLAY_COUNT,
|
||||
PLAY_AFTER_GRADE_RANK,
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL,
|
||||
UserAllRequestVO,
|
||||
UpsertResponseVO,
|
||||
UpsertUserAllError,
|
||||
ConfirmRequiredError,
|
||||
PatchError,
|
||||
# 节点结构
|
||||
NODE_TYPES,
|
||||
NODE_ORDER,
|
||||
BITS_TO_ARRAY,
|
||||
ARRAY_TO_BITS,
|
||||
DIFF_PRIMARY_KEYS,
|
||||
MAY_BE_EMPTY_NODES,
|
||||
DEFAULT_EMPTY_NODES,
|
||||
# 组包辅助
|
||||
empty_node,
|
||||
empty_user_all,
|
||||
coerce_node_value,
|
||||
sync_bits_to_arrays,
|
||||
build_delta,
|
||||
build_favorite_delta,
|
||||
build_user_all,
|
||||
build_user_playlog,
|
||||
build_user_game_playlog,
|
||||
build_post_play_user_data,
|
||||
validate_user_playlog,
|
||||
validate_before_send,
|
||||
apply_play_effects,
|
||||
# ★ 额外 JSON 合并 / 落库回查
|
||||
load_extra_data,
|
||||
merge_extra_data,
|
||||
verify_upsert_landing,
|
||||
# ★ 歌曲解锁(解禁):写进 userItemList 的 itemKind 5/6/7
|
||||
build_unlock_items,
|
||||
ITEM_KIND_MUSIC_UNLOCK,
|
||||
ITEM_KIND_MUSIC_UNLOCK_MASTER,
|
||||
ITEM_KIND_MUSIC_UNLOCK_REMASTER,
|
||||
ITEM_KIND_MUSIC_UNLOCK_STRONG,
|
||||
UNLOCK_MASTER_MIN_MUSIC_ID,
|
||||
default_user_weekly_data,
|
||||
default_user_2p_playlog,
|
||||
calc_play_special,
|
||||
normalize_snapshot,
|
||||
wait_send_cooldown,
|
||||
# 字段表
|
||||
PLAYLOG_FIELDS,
|
||||
PLAYLOG_BOOL_FIELDS,
|
||||
PLAYLOG_STRING_FIELDS,
|
||||
JUDGE_TYPES,
|
||||
JUDGE_BOXES,
|
||||
USER_DATA_DROP_KEYS,
|
||||
USER_OPTION_DROP_KEYS,
|
||||
USER_CHARACTER_KEEP_KEYS,
|
||||
USER_MUSIC_DETAIL_DROP_KEYS,
|
||||
USER_CHARGE_DROP_KEYS,
|
||||
USER_MISSION_DROP_KEYS,
|
||||
)
|
||||
|
||||
from .GetNeedUserData import (
|
||||
GetNeedUserData,
|
||||
save_snapshot,
|
||||
load_snapshot,
|
||||
summarize_snapshot,
|
||||
DEFAULT_SNAPSHOT_NODES,
|
||||
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
|
||||
PROJECT_ROOT,
|
||||
DATA_DIR,
|
||||
user_data_path,
|
||||
save_user_data,
|
||||
load_user_data,
|
||||
list_saved_user_ids,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ★ 买票前置闸门:GetUserCharge 必须先跑过(见 api/ChargeGuard.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .ChargeGuard import (
|
||||
CHARGE_READ_FRESH_SEC,
|
||||
has_fresh_read,
|
||||
held_charges,
|
||||
last_charge_list,
|
||||
last_read_age,
|
||||
note_charge_purchase,
|
||||
note_user_charge_read,
|
||||
purchased_charge_ids,
|
||||
reset as reset_charge_guard,
|
||||
)
|
||||
|
||||
from .UpsertUserChargelog import (
|
||||
# 票券主数据(原在 config.py,已下沉到 UpsertUserChargelog.py)
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
PAID_TICKET_IDS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TICKET_VALID_DAYS,
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
UserChargeVO,
|
||||
UserChargelogVO,
|
||||
UserChargelogRequestVO,
|
||||
ticket_name,
|
||||
ticket_price,
|
||||
calc_valid_date,
|
||||
parse_wall_time,
|
||||
now_cst,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) 取数公共底座
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .UserGetterCommon import (
|
||||
EmptyRequestVO as GetterEmptyRequestVO,
|
||||
UserIdRequestVO as GetterUserIdRequestVO,
|
||||
UserIdPageRequestVO,
|
||||
UserLoginBonusRequestVO,
|
||||
UserItemRequestVO,
|
||||
UserMusicRequestVO,
|
||||
UserCourseRequestVO,
|
||||
UserFavoriteRequestVO,
|
||||
UserFavoriteItemRequestVO,
|
||||
UserShopStockRequestVO,
|
||||
UserRivalRequestVO,
|
||||
UserRivalMusicRequestVO,
|
||||
get_all_pages,
|
||||
ITEM_KINDS,
|
||||
ITEM_NEXT_INDEX_FACTOR,
|
||||
FAVORITE_ITEM_KINDS,
|
||||
RIVAL_MUSIC_LEVELS,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5) 取数脚本(一个接口一个脚本)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .GetUserData import GetUserData
|
||||
from .GetUserExtend import GetUserExtend
|
||||
from .GetUserOption import GetUserOption
|
||||
from .GetUserCharacter import GetUserCharacter
|
||||
from .GetUserGhost import GetUserGhost
|
||||
from .GetUserMap import GetUserMap, GetUserMapAll
|
||||
from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
|
||||
from .GetUserRating import GetUserRating
|
||||
from .GetUserItem import GetUserItem, GetUserItemAll
|
||||
from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
|
||||
from .GetUserCourse import GetUserCourse, GetUserCourseAll
|
||||
from .GetUserCharge import GetUserCharge
|
||||
from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
|
||||
from .GetUserActivity import GetUserActivity
|
||||
from .GetUserMission import GetUserMissionData
|
||||
from .GetUserIntimate import GetUserIntimate
|
||||
from .GetUserShopStock import GetUserShopStock
|
||||
from .GetUserFavoriteItem import (
|
||||
GetUserFavoriteItem,
|
||||
GetUserFavoriteItemAll,
|
||||
FAVORITE_ITEM_KIND_MUSIC,
|
||||
FAVORITE_ITEM_KIND_RIVAL,
|
||||
)
|
||||
from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
|
||||
from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
|
||||
from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
|
||||
from .GetUserRegion import GetUserRegion
|
||||
from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
|
||||
from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
|
||||
|
||||
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_API_FUNCS = (
|
||||
# --- 会话链路 ---
|
||||
"ParseQRCode",
|
||||
"GetGameSettings",
|
||||
"Ping",
|
||||
"GetUserPreview",
|
||||
"UserLogin",
|
||||
"UserLogout",
|
||||
# --- 上传成绩 ---
|
||||
"UpsertUserAll",
|
||||
"build_play_from_config",
|
||||
"derive_play_characters",
|
||||
"merge_extra_data",
|
||||
"verify_upsert_landing",
|
||||
"build_user_all",
|
||||
"build_unlock_items",
|
||||
# --- 购买功能票 ---
|
||||
"UpsertUserChargelog",
|
||||
"ticket_name",
|
||||
"ticket_price",
|
||||
"calc_valid_date",
|
||||
"build_user_playlog",
|
||||
"build_user_game_playlog",
|
||||
"validate_user_playlog",
|
||||
"validate_before_send",
|
||||
"apply_play_effects",
|
||||
"build_post_play_user_data",
|
||||
"build_delta",
|
||||
"build_favorite_delta",
|
||||
"normalize_snapshot",
|
||||
"GetNeedUserData",
|
||||
"summarize_snapshot",
|
||||
"GetUserNewItemList",
|
||||
# --- 取数:单接口 ---
|
||||
"GetUserData", # GetUserDataApi
|
||||
"GetUserExtend", # GetUserExtendApi
|
||||
"GetUserOption", # GetUserOptionApi
|
||||
"GetUserCharacter", # GetUserCharacterApi
|
||||
"GetUserGhost", # GetUserGhostApi
|
||||
"GetUserMap", # GetUserMapApi(单页)
|
||||
"GetUserMapAll", # GetUserMapApi(全量)
|
||||
"GetUserLoginBonus", # GetUserLoginBonusApi(单页)
|
||||
"GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
|
||||
"GetUserRating", # GetUserRatingApi
|
||||
"GetUserItem", # GetUserItemApi(单类)
|
||||
"GetUserItemAll", # GetUserItemApi(全量)
|
||||
"GetUserMusicDetail", # GetUserMusicApi(单页)
|
||||
"GetUserMusicDetailAll", # GetUserMusicApi(全量)
|
||||
"GetUserCourse", # GetUserCourseApi(单页)
|
||||
"GetUserCourseAll", # GetUserCourseApi(全量)
|
||||
"GetUserCharge", # GetUserChargeApi
|
||||
"GetUserFavorite", # GetUserFavoriteApi(单类)
|
||||
"GetUserFavoriteAll", # GetUserFavoriteApi(全量)
|
||||
"GetUserActivity", # GetUserActivityApi
|
||||
"GetUserMissionData", # GetUserMissionDataApi
|
||||
"GetUserIntimate", # GetUserIntimateApi
|
||||
"GetUserShopStock", # GetUserShopStockApi
|
||||
"GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
|
||||
"GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
|
||||
"GetUserRivalData", # GetUserRivalDataApi
|
||||
"GetUserRivalMusic", # GetUserRivalMusicApi(单页)
|
||||
"GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
|
||||
"GetUserFriendBonus", # GetUserFriendBonusApi
|
||||
"GetUserKaleidxScope", # GetUserKaleidxScopeApi
|
||||
"GetGameKaleidxScope", # GetGameKaleidxScopeApi
|
||||
"GetUserRegion", # GetUserRegionApi
|
||||
"GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
|
||||
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
|
||||
"GetUserNewItemList", # GetUserNewItemListApi
|
||||
# --- 取数:游戏侧 ---
|
||||
"GetGameCharge", # GetGameChargeApi
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
autowrap(globals(), _API_FUNCS)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8) 统一导出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
__all__ = [
|
||||
# ---- 日志:会话 ----
|
||||
"setup_logging", "teardown_logging",
|
||||
# ---- 日志:自动跟踪 ----
|
||||
"autowrap", "get_called_apis",
|
||||
# ---- 日志:模式 ----
|
||||
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
|
||||
# ---- 日志:装饰器 / 兼容 ----
|
||||
"api_log", "get_logger",
|
||||
# ---- 日志:流程 ----
|
||||
"log_info", "log_step", "log_result", "log_warning", "log_error",
|
||||
"log_section", "log_field",
|
||||
# ---- 日志:收发数据 ----
|
||||
"log_debug", "log_request", "log_response", "log_payload",
|
||||
# ---- 网络核心 ----
|
||||
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
|
||||
"request", "request_dict", "request_vo",
|
||||
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
|
||||
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
|
||||
"vo_to_json", "vo_from_json",
|
||||
"local_utc_offset_seconds", "login_date_time_now",
|
||||
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
|
||||
# ---- 会话链路 ----
|
||||
"ParseQRCode",
|
||||
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
|
||||
"is_maintenance", "is_ping_disabled",
|
||||
"Ping",
|
||||
"GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
|
||||
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
|
||||
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
|
||||
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
|
||||
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
|
||||
# ---- 上传成绩 ----
|
||||
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
|
||||
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
|
||||
# ---- 歌曲解锁(解禁)----
|
||||
"build_unlock_items", "UNLOCK_MASTER_MIN_MUSIC_ID",
|
||||
"ITEM_KIND_MUSIC_UNLOCK", "ITEM_KIND_MUSIC_UNLOCK_MASTER",
|
||||
"ITEM_KIND_MUSIC_UNLOCK_REMASTER", "ITEM_KIND_MUSIC_UNLOCK_STRONG",
|
||||
# ---- 购买功能票 ----
|
||||
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
|
||||
# ---- 买票前置闸门(GetUserCharge 必须先跑过)----
|
||||
"CHARGE_READ_FRESH_SEC", "has_fresh_read", "held_charges", "last_charge_list",
|
||||
"last_read_age", "note_charge_purchase", "note_user_charge_read",
|
||||
"purchased_charge_ids", "reset_charge_guard",
|
||||
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
|
||||
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
|
||||
"UserAllRequestVO", "UpsertResponseVO",
|
||||
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
|
||||
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
|
||||
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
|
||||
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
|
||||
"build_delta", "build_favorite_delta", "build_user_all",
|
||||
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
|
||||
"validate_user_playlog", "validate_before_send", "apply_play_effects",
|
||||
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
|
||||
"normalize_snapshot", "wait_send_cooldown",
|
||||
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
|
||||
"JUDGE_TYPES", "JUDGE_BOXES",
|
||||
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
|
||||
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
|
||||
# ---- 快照编排 ----
|
||||
"GetNeedUserData", "save_snapshot", "load_snapshot",
|
||||
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
|
||||
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
|
||||
"save_user_data", "load_user_data", "list_saved_user_ids",
|
||||
# ---- 取数公共底座 ----
|
||||
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
|
||||
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
|
||||
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
|
||||
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
|
||||
"get_all_pages",
|
||||
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
|
||||
# ---- 取数脚本 ----
|
||||
"GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
|
||||
"GetUserMap", "GetUserMapAll",
|
||||
"GetUserLoginBonus", "GetUserLoginBonusAll",
|
||||
"GetUserRating",
|
||||
"GetUserItem", "GetUserItemAll",
|
||||
"GetUserMusicDetail", "GetUserMusicDetailAll",
|
||||
"GetUserCourse", "GetUserCourseAll",
|
||||
"GetUserCharge",
|
||||
"GetUserFavorite", "GetUserFavoriteAll",
|
||||
"GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
|
||||
"GetUserFavoriteItem", "GetUserFavoriteItemAll",
|
||||
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
|
||||
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
|
||||
"GetUserFriendBonus", "extract_gift_points",
|
||||
"GetUserKaleidxScope", "GetGameKaleidxScope",
|
||||
"GetUserRegion",
|
||||
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
|
||||
"GetUserNewItemList", "UserNewItemListRequestVO",
|
||||
"GetGameCharge", "GameChargeRequestVO",
|
||||
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
|
||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
|
||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
|
||||
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
||||
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
||||
]
|
||||
@@ -0,0 +1,563 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
|
||||
==========================================================================================================
|
||||
|
||||
【解决什么问题】
|
||||
config.py 第二节那六个值
|
||||
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
|
||||
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
|
||||
游戏服务器(<REDACTED>)**没有任何接口**会返回它们 ——
|
||||
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
|
||||
|
||||
本脚本把 AMDaemon 的这一步原样重做了一遍。
|
||||
|
||||
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
|
||||
端点 http://at.sys-all.cn/net/initialize @0x788170
|
||||
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
|
||||
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
|
||||
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
|
||||
请求头 Pragma: DFI @0x7883cc
|
||||
User-Agent: <...>Windows/Lite @0x788508
|
||||
响应字段 result= uri1= uri2= name= nickname= region0=
|
||||
region_name0..3= place_id= setting= country=
|
||||
location_type= client_timezone= res_ver= uri=
|
||||
utc_time= token= @0x784ae8-0x788fd0
|
||||
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
|
||||
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
|
||||
|
||||
【信封细节】实测确认
|
||||
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
|
||||
响应 = 16 字节 IV || AES-CBC 密文
|
||||
|
||||
【响应码】
|
||||
result= 1 成功
|
||||
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
|
||||
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
|
||||
|
||||
【用法】
|
||||
python authv1.py A63E01E9580 # 查(打印结果)
|
||||
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
|
||||
python authv1.py A63E01E9580 --json # 机器可读
|
||||
python authv1.py A63E01E9580 --write # 强制写回 config.py
|
||||
python authv1.py A63E01E9580 --no-write # 强制不写
|
||||
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
|
||||
|
||||
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
|
||||
|
||||
【也当协议库用】
|
||||
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
|
||||
netdelivery.py 就是这么用它的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量
|
||||
# =============================================================================
|
||||
|
||||
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
|
||||
# 默认用 sys-allnet.cn(与 <REDACTED> 同机房,链路更短)。
|
||||
ALLNET_HOSTS = (
|
||||
"http://at.sys-allnet.cn",
|
||||
"http://at.sys-all.cn",
|
||||
)
|
||||
DEFAULT_HOST = ALLNET_HOSTS[0]
|
||||
|
||||
PATH_INITIALIZE = "/net/initialize"
|
||||
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
|
||||
|
||||
TITLE_ID = "SDGB"
|
||||
|
||||
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
|
||||
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
|
||||
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
|
||||
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
|
||||
AES_IV = bytes(16)
|
||||
|
||||
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
|
||||
HEADERS = {
|
||||
"User-Agent": "SDGB;Windows/Lite",
|
||||
"Pragma": "DFI",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
DEFAULT_TIMEOUT = 20
|
||||
|
||||
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
|
||||
CLIENT_ID_LEN = 11
|
||||
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
|
||||
|
||||
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
|
||||
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
|
||||
DEFAULT_TITLE_VER = "1.55"
|
||||
|
||||
RESULT_TEXT = {
|
||||
"1": "成功",
|
||||
"-1": "通用错误",
|
||||
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
|
||||
"-3": "参数格式错误(client_id 必须是 11 位短值)",
|
||||
}
|
||||
|
||||
|
||||
class AllnetError(RuntimeError):
|
||||
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 信封:AES-128-CBC
|
||||
# =============================================================================
|
||||
|
||||
def encode_request(text: str) -> bytes:
|
||||
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
|
||||
body = AES_IV + text.encode("utf-8")
|
||||
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
|
||||
|
||||
|
||||
def decode_response(raw: bytes) -> str:
|
||||
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
|
||||
if len(raw) < 32 or len(raw) % AES.block_size:
|
||||
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
|
||||
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
|
||||
n = plain[-1]
|
||||
if 1 <= n <= AES.block_size:
|
||||
plain = plain[:-n]
|
||||
return plain.decode("utf-8", "replace")
|
||||
|
||||
|
||||
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
|
||||
"""
|
||||
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
|
||||
|
||||
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
|
||||
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
|
||||
"""
|
||||
url = host.rstrip("/") + path
|
||||
payload = encode_request(body)
|
||||
last_error = None
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
time.sleep(1.5 * attempt)
|
||||
continue
|
||||
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
|
||||
|
||||
if resp.status_code != 200:
|
||||
if resp.status_code >= 500 and attempt < retries:
|
||||
last_error = "HTTP %d" % resp.status_code
|
||||
time.sleep(1.5 * attempt)
|
||||
continue
|
||||
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
|
||||
return decode_response(resp.content)
|
||||
|
||||
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
|
||||
|
||||
|
||||
def parse_kv(text: str) -> dict:
|
||||
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
|
||||
out = {}
|
||||
for part in text.strip().split("&"):
|
||||
if not part or "=" not in part:
|
||||
continue
|
||||
key, val = part.split("=", 1)
|
||||
out[key.strip()] = urllib.parse.unquote(val)
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Keychip <-> client_id
|
||||
# =============================================================================
|
||||
|
||||
def parse_keychip(raw: str) -> str:
|
||||
"""
|
||||
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
|
||||
|
||||
接受三种写法:
|
||||
完整带连字符 "A63E-01C30471007"
|
||||
完整连写 "A63E01C30471007"
|
||||
短值 "A63E01C3047"
|
||||
|
||||
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
|
||||
ALL.Net 回 result=-3(格式错)。
|
||||
"""
|
||||
s = re.sub(r"[\s\-]", "", str(raw)).upper()
|
||||
if len(s) < CLIENT_ID_LEN:
|
||||
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
|
||||
cid = s[:CLIENT_ID_LEN]
|
||||
if not CLIENT_ID_RE.match(cid):
|
||||
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
|
||||
return cid
|
||||
|
||||
|
||||
def make_keychip(client_id: str, place_id) -> str:
|
||||
"""
|
||||
按本工程约定拼回完整 Keychip:
|
||||
前 4 位 + "-" + 第 5 位起 + placeId
|
||||
例:A63E01C3047 + 1007 -> A63E-01C30471007
|
||||
"""
|
||||
cid = str(client_id).upper()
|
||||
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 两个接口
|
||||
# =============================================================================
|
||||
|
||||
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT):
|
||||
"""POST /net/initialize —— 机台认证,返回原始明文。"""
|
||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
|
||||
|
||||
|
||||
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT):
|
||||
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
|
||||
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
|
||||
|
||||
|
||||
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||
timeout=DEFAULT_TIMEOUT, raw_ok=False):
|
||||
"""
|
||||
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
|
||||
|
||||
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
|
||||
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
|
||||
"""
|
||||
cid = parse_keychip(keychip)
|
||||
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
|
||||
kv = parse_kv(text)
|
||||
|
||||
result = kv.get("result", "")
|
||||
place_hex = kv.get("place_id", "") or ""
|
||||
try:
|
||||
place_id = int(place_hex, 16) if place_hex else None
|
||||
except ValueError:
|
||||
place_id = None
|
||||
|
||||
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
|
||||
region_names = [n for n in region_names if n]
|
||||
|
||||
try:
|
||||
region_id = int(kv.get("region0", "") or 0)
|
||||
except ValueError:
|
||||
region_id = None
|
||||
|
||||
info = {
|
||||
"ok": result == "1",
|
||||
"result": result,
|
||||
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
|
||||
"title_id": TITLE_ID,
|
||||
"title_ver": title_ver,
|
||||
"host": host,
|
||||
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
|
||||
"client_id": cid,
|
||||
"region_id": region_id,
|
||||
"place_id": place_id,
|
||||
"place_name": kv.get("name", ""),
|
||||
"region_name": region_names[0] if region_names else "",
|
||||
# --- 附送 ---
|
||||
"nickname": kv.get("nickname", ""),
|
||||
"region_names": region_names,
|
||||
"country": kv.get("country", ""),
|
||||
"setting": kv.get("setting", ""),
|
||||
"location_type": kv.get("location_type", ""),
|
||||
"game_server_uri": kv.get("uri1", ""),
|
||||
"auth_token": kv.get("uri2", ""),
|
||||
"token": kv.get("token", ""),
|
||||
"utc_time": kv.get("utc_time", ""),
|
||||
"client_timezone": kv.get("client_timezone", ""),
|
||||
"res_ver": kv.get("res_ver", ""),
|
||||
"raw": text.strip(),
|
||||
}
|
||||
if info["place_id"] is not None:
|
||||
info["keychip"] = make_keychip(cid, info["place_id"])
|
||||
else:
|
||||
info["keychip"] = ""
|
||||
|
||||
if not info["ok"] and not raw_ok:
|
||||
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
|
||||
return info
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 写回 config.py
|
||||
# =============================================================================
|
||||
|
||||
CONFIG_FILENAME = "config.py"
|
||||
|
||||
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
|
||||
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
|
||||
|
||||
|
||||
def _replace_assignment(text, name, literal):
|
||||
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
|
||||
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
|
||||
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
|
||||
if count != 1:
|
||||
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
|
||||
return new_text
|
||||
|
||||
|
||||
def write_back_config(info, *, config_path=None, quiet=False):
|
||||
"""
|
||||
把查询结果写回 config.py。
|
||||
|
||||
安全措施:
|
||||
1. 先 ast.parse 语法自检,写坏了就不落盘
|
||||
2. 落盘前把原文件备份成 config.py.bak
|
||||
3. 保留原文件的换行风格(LF / CRLF)
|
||||
"""
|
||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
CONFIG_FILENAME)
|
||||
if not os.path.isfile(path):
|
||||
raise AllnetError("找不到 config.py:%s" % path)
|
||||
|
||||
with open(path, "rb") as fp:
|
||||
raw = fp.read()
|
||||
newline = "\r\n" if b"\r\n" in raw else "\n"
|
||||
text = raw.decode("utf-8").replace("\r\n", "\n")
|
||||
|
||||
pairs = [
|
||||
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
|
||||
("REGION_ID", str(info["region_id"])),
|
||||
("PLACE_ID", str(info["place_id"])),
|
||||
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
|
||||
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
|
||||
]
|
||||
for name, literal in pairs:
|
||||
text = _replace_assignment(text, name, literal)
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
except SyntaxError as exc:
|
||||
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
|
||||
|
||||
shutil.copy2(path, path + ".bak")
|
||||
with open(path, "wb") as fp:
|
||||
fp.write(text.replace("\n", newline).encode("utf-8"))
|
||||
|
||||
if not quiet:
|
||||
print(" [已写回] %s" % path)
|
||||
print(" [已备份] %s.bak" % path)
|
||||
return path
|
||||
|
||||
|
||||
def config_switch_enabled(config_path=None):
|
||||
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
|
||||
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
CONFIG_FILENAME)
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
tree = ast.parse(fp.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return False
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
|
||||
try:
|
||||
return bool(ast.literal_eval(node.value))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 输出
|
||||
# =============================================================================
|
||||
|
||||
def format_report(info) -> str:
|
||||
"""人类可读的结果块。"""
|
||||
lines = []
|
||||
lines.append("=== authv1 机台身份查询 ===")
|
||||
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
|
||||
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
|
||||
lines.append(" placeId : %s" % info["place_id"])
|
||||
lines.append(" placeName : %s" % info["place_name"])
|
||||
lines.append(" regionId : %s" % info["region_id"])
|
||||
lines.append(" regionName : %s" % info["region_name"])
|
||||
lines.append(" --- 附送 ---")
|
||||
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
|
||||
lines.append(" 机台昵称 : %s" % info["nickname"])
|
||||
lines.append(" 国家/地区 : %s" % info["country"])
|
||||
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
|
||||
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="authv1.py",
|
||||
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python authv1.py A63E01E9580\n"
|
||||
" python authv1.py A63E-01C30471007 --json\n"
|
||||
" python authv1.py A63E01E9580 --write\n"
|
||||
" python authv1.py --batch source/keychip.csv --limit 10\n"
|
||||
),
|
||||
)
|
||||
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
|
||||
p.add_argument("--batch", metavar="CSV",
|
||||
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
|
||||
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
|
||||
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
|
||||
p.add_argument("--json", action="store_true", help="输出 JSON")
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
|
||||
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
|
||||
return p
|
||||
|
||||
|
||||
def resolve_title_ver(cli_value):
|
||||
if cli_value:
|
||||
return str(cli_value)
|
||||
try:
|
||||
import config # noqa: F401 同目录
|
||||
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
|
||||
except Exception:
|
||||
return DEFAULT_TITLE_VER
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
try:
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
title_ver = resolve_title_ver(args.ver)
|
||||
|
||||
if args.batch:
|
||||
return run_batch(args, title_ver)
|
||||
|
||||
if not args.keychip:
|
||||
build_parser().print_help()
|
||||
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
|
||||
return 2
|
||||
|
||||
try:
|
||||
info = query_keychip(args.keychip, title_ver=title_ver,
|
||||
host=args.host, timeout=args.timeout)
|
||||
except (AllnetError, ValueError) as exc:
|
||||
print("查询失败:%s" % exc)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(info, ensure_ascii=False, indent=1))
|
||||
else:
|
||||
print(format_report(info))
|
||||
|
||||
do_write = decide_write(args)
|
||||
if do_write:
|
||||
try:
|
||||
write_back_config(info)
|
||||
except AllnetError as exc:
|
||||
print("写回失败:%s" % exc)
|
||||
return 1
|
||||
elif not args.json:
|
||||
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
|
||||
"或加 --write")
|
||||
return 0
|
||||
|
||||
|
||||
def decide_write(args):
|
||||
"""--write / --no-write 优先,否则看 config.py 的开关。"""
|
||||
if args.write:
|
||||
return True
|
||||
if args.no_write:
|
||||
return False
|
||||
return config_switch_enabled()
|
||||
|
||||
|
||||
def _disp_width(text):
|
||||
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
|
||||
import unicodedata
|
||||
width = 0
|
||||
for ch in str(text):
|
||||
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||||
return width
|
||||
|
||||
|
||||
def _pad(text, width):
|
||||
text = str(text)
|
||||
return text + " " * max(0, width - _disp_width(text))
|
||||
|
||||
|
||||
def run_batch(args, title_ver):
|
||||
path = args.batch
|
||||
if not os.path.isfile(path):
|
||||
print("找不到 CSV:%s" % path)
|
||||
return 2
|
||||
|
||||
rows = []
|
||||
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
|
||||
reader = csv.reader(fp)
|
||||
for row in reader:
|
||||
if not row or not row[0].strip():
|
||||
continue
|
||||
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
|
||||
continue
|
||||
rows.append(row)
|
||||
if args.limit > 0:
|
||||
rows = rows[: args.limit]
|
||||
|
||||
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
|
||||
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
|
||||
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
|
||||
print(header)
|
||||
ok = 0
|
||||
results = []
|
||||
for row in rows:
|
||||
try:
|
||||
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
|
||||
timeout=args.timeout, raw_ok=True)
|
||||
except Exception as exc:
|
||||
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
|
||||
continue
|
||||
results.append(info)
|
||||
if info["ok"]:
|
||||
ok += 1
|
||||
print(" ".join([
|
||||
_pad(info["client_id"], 13),
|
||||
_pad(info["result"], 6),
|
||||
_pad(info["place_id"], 8),
|
||||
_pad(info["region_id"], 8),
|
||||
_pad(info["region_name"], 8),
|
||||
str(info["place_name"]),
|
||||
]))
|
||||
print("--- 成功 %d / %d ---" % (ok, len(rows)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登出
|
||||
================================================================================
|
||||
|
||||
一条完整的「买票」链路:
|
||||
|
||||
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
|
||||
|
||||
★ 本脚本判断「能买什么票」**只看 GetUserChargeApi**(账号自己的票券表),
|
||||
不再调 GetGameChargeApi(那是机台本地的上架清单,跟账号状态无关)。
|
||||
|
||||
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
|
||||
2. 调 GetUserDataApi 取 playCount / playerRating
|
||||
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
|
||||
4. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
|
||||
userChargelog.price(= ticket.creditNum)
|
||||
5. 闸门 → 发包
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
python buy_ticket.py <二维码> # 预检:只看报文,不发包
|
||||
python buy_ticket.py <二维码> --list # 登录后只列当前票券,不购买
|
||||
python buy_ticket.py <二维码> --charge-id 3 # 指定票(默认 5 = 5倍券)
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --debug
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
|
||||
|
||||
付费票(api.PAID_TICKET_IDS):
|
||||
2 = 2倍チケット creditNum 1
|
||||
3 = 3倍チケット creditNum 2
|
||||
4 = 4倍チケット creditNum 3
|
||||
5 = 5倍チケット creditNum 4
|
||||
6 = 6倍チケット creditNum 5
|
||||
|
||||
★ 真实购买会写入一条购买记录(不可撤销)。不传 --send 时只构造报文。
|
||||
|
||||
★ 二维码有**时效性**(**有效期 10 分钟**),过期后 aimedb 返回 errorID=2
|
||||
—— **不是按次数计量的**,重新生成即可。
|
||||
|
||||
★ 购买前会先查一次功能票;**账号已持有功能票时直接拒绝购买**(重复购买有封号风险)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_error,
|
||||
log_info,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
GetUserCharge,
|
||||
ticket_name,
|
||||
GetNeedUserData,
|
||||
summarize_snapshot,
|
||||
Ping,
|
||||
)
|
||||
from config import PLACE_ID, REGION_ID
|
||||
|
||||
|
||||
def show_charges(user_id, label):
|
||||
"""
|
||||
★ 调 GetUserChargeApi 打印当前票券状态,并返回 stock > 0 的那些。
|
||||
|
||||
★★ 为什么跑前跑后都要查:
|
||||
真机只在「本局要用这张票」时才购买,买到手会立刻被用掉(stock--),
|
||||
所以正常状态 stock 恒为 0。
|
||||
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
|
||||
所以买之前必须确认没有持票;买之后要确认状态符合预期。
|
||||
"""
|
||||
raw = GetUserCharge(user_id)
|
||||
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
|
||||
if not lst:
|
||||
log_info(" (空)")
|
||||
for c in lst:
|
||||
cid = c.get("chargeId")
|
||||
stock = int(c.get("stock", 0) or 0)
|
||||
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
|
||||
+ " stock=" + str(stock)
|
||||
+ " purchaseDate=" + str(c.get("purchaseDate"))
|
||||
+ " validDate=" + str(c.get("validDate"))
|
||||
+ (" ← ★ 持有中" if stock > 0 else ""))
|
||||
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
|
||||
return held
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
|
||||
"debug": False, "skip_entry": False, "wait_before": 15.0}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--list":
|
||||
opts["list"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--skip-entry":
|
||||
opts["skip_entry"] = True
|
||||
elif a == "--wait-before" and i + 1 < len(argv):
|
||||
opts["wait_before"] = float(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--charge-id" and i + 1 < len(argv):
|
||||
opts["charge_id"] = int(argv[i + 1])
|
||||
i += 1
|
||||
elif not a.startswith("--") and not opts["qr"]:
|
||||
opts["qr"] = a
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["qr"]:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts["debug"]:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
setup_logging("buy_ticket")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
|
||||
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
|
||||
# 不跑的话 number 恒为 "0"(真机是 75)。
|
||||
Ping()
|
||||
|
||||
user_id, token = ParseQRCode(opts["qr"])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
preview = GetUserPreview(user_id, token)
|
||||
if preview.isLogin:
|
||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
# ---- ★ 复刻真机的「入场」序列:登录后先把用户数据全量拉一遍 ----
|
||||
# 真机 13:19:45 登录 -> 13:19:46~13:19:51 拉 47 个节点
|
||||
# -> 13:20:10 才发 UpsertUserChargelogApi
|
||||
# 本脚本原来 login 后直接买,跳过了这一段。
|
||||
if not opts["skip_entry"]:
|
||||
snap = GetNeedUserData(user_id, token=token,
|
||||
place_id=PLACE_ID, region_id=REGION_ID)
|
||||
_sum = summarize_snapshot(snap)
|
||||
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
|
||||
+ str(len(snap.get("_failed") or [])) + " 个")
|
||||
|
||||
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
|
||||
held_before = show_charges(user_id, "购买前")
|
||||
|
||||
if opts["list"]:
|
||||
log_info("[--list] 只查询票券状态,不购买。")
|
||||
return 0
|
||||
|
||||
aborted = False
|
||||
if held_before:
|
||||
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
|
||||
for c in held_before:
|
||||
log_error(" chargeId=" + str(c.get("chargeId"))
|
||||
+ " " + ticket_name(c.get("chargeId"))
|
||||
+ " stock=" + str(c.get("stock"))
|
||||
+ " validDate=" + str(c.get("validDate")))
|
||||
log_error("请先在机台把票用掉,或等它过期后再来。")
|
||||
aborted = True
|
||||
else:
|
||||
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
|
||||
# 默认 15 秒(与 UpsertUserAll 的发包冷却一致)
|
||||
if opts["wait_before"] > 0:
|
||||
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
|
||||
time.sleep(opts["wait_before"])
|
||||
log_info("等待结束,开始购买")
|
||||
|
||||
# ---- 购买 ----
|
||||
# ★ 一个函数搞定:取当前票券/档案 -> 安全检查 -> 算 validDate/price -> 闸门 -> 发包
|
||||
UpsertUserChargelog(
|
||||
user_id, opts["charge_id"], login.loginDateTime,
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
guard_no_stock=True,
|
||||
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
|
||||
# ---- ★★ 跑后:无论有没有真发包,都再查一次 ----
|
||||
# (中止时也查,确认状态确实没被动过)
|
||||
held_after = show_charges(user_id, "购买后")
|
||||
|
||||
if aborted:
|
||||
log_info("已按安全检查中止,**未发包**;上面是中止后的复查结果")
|
||||
if len(held_after) == len(held_before):
|
||||
log_info("复查确认:票券状态未发生变化 ✅")
|
||||
else:
|
||||
log_warning("复查发现票券数量变了 —— 请人工确认")
|
||||
return 3
|
||||
|
||||
if not opts["send"]:
|
||||
log_info("[dry-run] 未发包。想真实购买请加 --send(★ 会写入购买记录)")
|
||||
return 0
|
||||
if held_after:
|
||||
log_info("购买后持有 " + str(len(held_after)) + " 张票(购买成功)")
|
||||
else:
|
||||
log_warning("购买后没有查到持有中的票 —— 服务端可能没落库,"
|
||||
"或票被立刻消耗了")
|
||||
return 0
|
||||
|
||||
except ChargelogGuardError:
|
||||
return 3
|
||||
except SystemExit:
|
||||
raise
|
||||
except ChargelogConfirmError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,405 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
|
||||
================================================================================
|
||||
|
||||
★★ 这是**模板文件**。第一次使用请先复制一份:
|
||||
|
||||
cp config.py.example config.py # Linux / macOS
|
||||
copy config.py.example config.py # Windows
|
||||
|
||||
然后**必须**填好第二节「机台与店铺标识」的 6 个值,否则脚本发出去的请求
|
||||
带的是别人的机台身份,服务端会拒绝。
|
||||
|
||||
★ config.py 已在 .gitignore 里 —— 它属于你的私有配置,不要提交。
|
||||
================================================================================
|
||||
|
||||
本文件集中存放**用户可能需要修改**的内容,其余协议常量写死在各自脚本内。
|
||||
|
||||
分六块:
|
||||
1. 网络与加密常量(AES 密钥、协议版本、服务器地址)
|
||||
2. 机台与店铺标识(Keychip、区域、店铺)
|
||||
3. 扫码登录常量(aimedb)
|
||||
4. ★ UpsertUserAll 即将上传的歌曲数据(默认取 source/2026-10-05.log 真机样本)
|
||||
5. UpsertUserAll 安全闸门
|
||||
6. 日志级别
|
||||
|
||||
权威来源全部标注在注释里,改之前请先看依据。
|
||||
"""
|
||||
|
||||
# =============================================================================
|
||||
# 一、网络与加密常量
|
||||
# =============================================================================
|
||||
|
||||
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/MaimaiNet.py
|
||||
# --- 报文版本标识 ---
|
||||
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
|
||||
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
|
||||
MAI_ENCODING = "1.55"
|
||||
|
||||
# --- 超时(毫秒)---
|
||||
# 依据:source/Assembly-CSharp/Net/NetConfig.cs:7-11
|
||||
TIMEOUT_MS = 10000 # 普通接口
|
||||
TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 二、机台与店铺标识
|
||||
# =============================================================================
|
||||
|
||||
# 客户端 ID(Keychip 短值)
|
||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
|
||||
# clientId = AMDaemon.System.KeychipId.ShortValue
|
||||
# 实测:真机日志里 clientId 恒等于 Keychip 短值
|
||||
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
|
||||
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
|
||||
|
||||
# 区域 ID / 店铺 ID
|
||||
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
|
||||
# regionId = Auth.RegionCode ; placeId = Auth.LocationId
|
||||
# 实测:真机日志里的 regionId / placeId 与机台所在地一致
|
||||
REGION_ID = 0 # ★★ 必填:你所在区域 ID
|
||||
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
|
||||
|
||||
# Keychip ID(机台唯一标识,完整格式带连字符)
|
||||
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
|
||||
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
|
||||
# KeychipID = AMDaemon.System.KeychipId.Value
|
||||
# ★ 组成 = CLIENT_ID 的前 4 位 + "-" + CLIENT_ID 第 5 位起 + PLACE_ID
|
||||
# 例:CLIENT_ID "A63E01C3047" + PLACE_ID 1007 -> "A63E-01C30471007"
|
||||
# 所以它由上面两个值**推导**,不要手改。
|
||||
KEYCHIP_ID = (CLIENT_ID[:4] + "-" + CLIENT_ID[4:] + str(PLACE_ID)) if CLIENT_ID else ""
|
||||
|
||||
# 店铺名 / 区域名
|
||||
#
|
||||
# ★ 为什么以前必须手工填:**游戏服务器没有任何接口返回店铺名**。
|
||||
# 逐行扫过 source/2026-10-05.log 全部响应:
|
||||
# - GetGameSettingApi 只回 gameSetting,无店名
|
||||
# - GetUserDataApi 的 lastPlaceName 回的是 null
|
||||
# - GetUserRegionApi 只回 regionId/playCount/created
|
||||
# 店铺名来自机台本地 AMDaemon:
|
||||
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
|
||||
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
|
||||
#
|
||||
# ★★ 现在不用手工抄了:python authv1.py <你的Keychip> 会直接问 ALL.Net
|
||||
# 认证服务器要回 name / region_name0。把下面的 AUTHV1_WRITE_CONFIG 打开,
|
||||
# 它还能把结果自动写回 config.py。
|
||||
PLACE_NAME = "" # ★★ 必填:店铺名
|
||||
REGION_NAME = "" # ★★ 必填:区域名
|
||||
|
||||
|
||||
# --- authv1 回写开关 ---
|
||||
#
|
||||
# authv1.py(Keychip -> 机台身份)查到结果后,是否**自动写回 config.py**。
|
||||
#
|
||||
# True : 每次查询成功后,把 CLIENT_ID / REGION_ID / PLACE_ID /
|
||||
# PLACE_NAME / REGION_NAME 五个值改写进 config.py。
|
||||
# (KEYCHIP_ID 是推导值,会自动跟着变,不用写。)
|
||||
# 改写前会把原文件备份成 config.py.bak,写坏不落盘。
|
||||
# False : 只打印结果,不动 config.py。(默认,安全)
|
||||
#
|
||||
# 命令行可临时覆盖:--write 强制写 / --no-write 强制不写。
|
||||
AUTHV1_WRITE_CONFIG = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 三、扫码登录常量(aimedb)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs/舞萌DX_扫码登录协议技术规范.md
|
||||
|
||||
# ★ aimedb / QR 常量已下沉到 api/Chime.py
|
||||
# =============================================================================
|
||||
# 三之二、★ 功能票(Ticket)主数据
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:游戏本地主数据
|
||||
# D:\SDGB2026\Package\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
||||
#
|
||||
# ★ 为什么脚本需要它:
|
||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
||||
# result.price = ticket.creditNum;
|
||||
#
|
||||
# ticketKind 含义(Ticket.xml 里的枚举):
|
||||
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
|
||||
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
|
||||
# 已下沉到 api/UpsertUserChargelog.py
|
||||
|
||||
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
|
||||
UPSERT_CHARGELOG_CONFIRM = False
|
||||
# =============================================================================
|
||||
# 四、★ UpsertUserAll 即将上传的歌曲数据
|
||||
# =============================================================================
|
||||
#
|
||||
# ★ 为什么放在 config:
|
||||
# 一局游玩的成绩是**唯一无法由脚本自行产生**的输入 ——
|
||||
# 纯网络脚本复刻不出游玩过程,服务端要的就是这份原始成绩。
|
||||
# 换歌 / 换成绩只改这里,不用动任何 api 脚本。
|
||||
#
|
||||
# ★ 默认值 = source/2026-10-05.log:123 的**真机跑通报文**(UpsertUserAllApi 请求)
|
||||
# 即 userPlaylogList[0] 逐字段照抄。
|
||||
#
|
||||
# ★ 两个"歌曲数据"的区别(别混):
|
||||
# userPlaylogList 本局**原始判定输入** —— 服务端据此算分、写战绩表
|
||||
# userMusicDetailList 按 (musicId, level) 的**历史归档** —— 本局那条 playCount +1
|
||||
# 下面 PLAY_* 同时喂给这两处,保证一致。
|
||||
|
||||
# --- 曲目与难度 ---
|
||||
PLAY_MUSIC_ID = 143 # 曲目 ID
|
||||
PLAY_MUSIC_LEVEL = 4 # 0=Basic 1=Advanced 2=Expert 3=Master 4=Re:Master
|
||||
PLAY_TRACK_NO = 1 # 本局第几首
|
||||
|
||||
# --- 版本号(同一版本两种写法,两处都要)---
|
||||
PLAY_VERSION_INT = 1056000 # 整数形式,用于 userPlaylogList[].version(1.56.00)
|
||||
PLAY_VERSION_STR = "1.56.00" # 字符串形式,用于 userGamePlaylogList[].version
|
||||
|
||||
# --- 成绩 ---
|
||||
PLAY_ACHIEVEMENT = 0 # 达成率 ×10000(0 = 本局失败)
|
||||
PLAY_DELUXSCORE = 0 # DX 分数
|
||||
PLAY_SCORE_RANK = 0 # 评级档(0 = 无)
|
||||
PLAY_IS_CLEAR = False # 是否 Clear
|
||||
|
||||
PLAY_MAX_COMBO = 0
|
||||
PLAY_TOTAL_COMBO = 655 # 该谱面 note 总数(= 各类型判定数之和)
|
||||
PLAY_MAX_SYNC = 0
|
||||
PLAY_TOTAL_SYNC = 0
|
||||
PLAY_COMBO_STATUS = 0 # 0=无 1=全连
|
||||
PLAY_SYNC_STATUS = 0
|
||||
|
||||
PLAY_FAST_COUNT = 0
|
||||
PLAY_LATE_COUNT = 0
|
||||
|
||||
# --- 判定明细 {类型: {判定位: 次数}} ---
|
||||
# 真机样本是一局**失败**的成绩(全 Miss)
|
||||
PLAY_JUDGE_COUNTS = {
|
||||
"tap": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 441},
|
||||
"hold": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 19},
|
||||
"slide": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 192},
|
||||
"touch": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 0},
|
||||
"break": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 3},
|
||||
}
|
||||
|
||||
# --- 评级(本局前后)---
|
||||
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
|
||||
# --- 曲种索引 ---
|
||||
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
|
||||
PLAY_CATEGORY_INDEX = 2022
|
||||
PLAY_MUSIC_INDEX = 10 # userExtend.musicIndex
|
||||
PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
|
||||
|
||||
# --- 本局使用的角色快照(characterId1..5 / Level / Awakening)---
|
||||
#
|
||||
# ★ 语义:**进入游玩时**的快照值,不要加经验 —— 这一点极易搞错。
|
||||
# (结算后的等级在 upsertUserAll.userCharacterList 里,那处才 +经验)
|
||||
# 真机实证(source/2026-10-05.log:123 同一份报文):
|
||||
# userPlaylogList[0].characterLevel1..5 = 3800/4455/134/134/134 ← 快照(游玩前)
|
||||
# upsertUserAll.userCharacterList = 3801/4456/135/135/135 ← 结算后
|
||||
#
|
||||
# ★★ 通常**不用改这里**:main.py 会调用 derive_play_characters(snapshot),
|
||||
# 直接从服务器刚下发的 userData.charaSlot + userCharacterList 推出游玩前等级,
|
||||
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
|
||||
#
|
||||
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||||
# --- 本局消耗 ---
|
||||
# ★ playCredit 语义(VOExtensions.cs:838-845):
|
||||
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
|
||||
# 即"这一局总共折合多少 credit",不是余额、不是曲目数。真机样本 = 1。
|
||||
PLAY_CREDIT = 1
|
||||
# ★ -1 = 不使用票。依据 TicketManager.cs:47 初始化 _useTicketId[i] = -1;
|
||||
# GetTicketCredit(-1) 里 GetTicket(-1) 返回 null -> 返回 0,即不用票不折算 credit。
|
||||
PLAY_USE_TICKET_ID = -1
|
||||
PLAY_MODE = 0 # 0=通常
|
||||
PLAY_IS_TUTORIAL = False
|
||||
PLAY_IS_EVENT_MODE = False
|
||||
PLAY_IS_NEW_FREE = False
|
||||
PLAY_IS_FREEDOM_MODE = False
|
||||
|
||||
# --- 机台本地计数器(★ 无法从服务器推导,只能手填)---
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:881
|
||||
# result.playCount = Backup.localParameter.playCount
|
||||
# 这是机台**本地**的游玩计数器,任何 Get 接口都不会返回它,
|
||||
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
|
||||
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
|
||||
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
|
||||
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:846-855
|
||||
#
|
||||
# playSpecial = 位反转32( Random.Range(1,1037933) * 2069 + CalcSpecialNum() )
|
||||
#
|
||||
# CalcSpecialNum() 是**按位或**出来的机台特征掩码,逐位含义(源码原文):
|
||||
# 0x001 SpecialAP (测试/特殊 AP)
|
||||
# 0x002 IsAutoPlay()
|
||||
# 0x004 Win32.getRawInputDeviceCount 的 keyboard > 0
|
||||
# 0x008 同上 mouse > 0
|
||||
# 0x010 config.IsAutoPlay / IsDebugDisp / IsAllOpen
|
||||
# 0x020 AMDaemon.System.IsDevelop
|
||||
# 0x040 !SystemInfo.deviceName.Contains("ACA") ← 真机是 ACA,故该位为 0
|
||||
# 0x080 !graphicsDeviceName.Contains("GeForce GTX") ← 真机是 GTX,故该位为 0
|
||||
# 0x100 !processorType.Contains("Core(TM) i3-8100") ← 真机是 i3-8100,故该位为 0
|
||||
# 0x400 无条件置位(源码 GameManager.cs:375) ← ★ 恒为 1
|
||||
#
|
||||
# ★ 真机实测(gamehook 抓到 CalcSpecialNum 调用):
|
||||
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
||||
# 1024 = 0x400,即除"无条件置位"那一位外全部为 0 —— 正好是一台标准 ACA 机台。
|
||||
#
|
||||
# ★ 反推验证:真机 playSpecial = 892014272
|
||||
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
#
|
||||
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
|
||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
|
||||
# --- 用户总游玩次数增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
|
||||
# 想复刻真机的「+1」就改成 1。
|
||||
#
|
||||
# 为什么默认改成 0:
|
||||
# playCount 是账号的**总游玩次数**,是玩家可见的统计值;
|
||||
# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。
|
||||
# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断,
|
||||
# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。
|
||||
PLAY_USER_PLAY_COUNT_GAIN = 0
|
||||
|
||||
# --- 曲目历史游玩次数增量 ---
|
||||
#
|
||||
# ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。
|
||||
# ★★ 注意语义:是「条目照传、次数不变」,**不是**「不回传这个节点」。
|
||||
# 实测(同一份代码、同一账号,唯一差别就是这个节点):
|
||||
# userMusicDetailList 回传 1 条 -> returnCode=1 且 playCount 真的 +1(落库)
|
||||
# userMusicDetailList 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**)
|
||||
# 所以「不想让乐曲 playCount 增加」必须靠把**增量**设成 0,
|
||||
# 而不是把整个节点清空 —— 后者会让整包被服务端静默丢弃。
|
||||
#
|
||||
# 想复刻真机的「该谱面 playCount +1」就改成 1。
|
||||
PLAY_MUSIC_PLAY_COUNT_GAIN = 0
|
||||
|
||||
# --- 地图进度增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **本局不推进地图**,userMapList 回传空数组。
|
||||
# 想复刻真机的"地图 +11000"就改成 11000(真机样本:mapId=550003 的
|
||||
# distance 1424000 -> 1435000)。
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs 的地图导出逻辑
|
||||
# —— 没变化的条目不会进差分结果。
|
||||
PLAY_MAP_DISTANCE_GAIN = 0
|
||||
|
||||
# --- 组队角色等级增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **本局不给角色加经验**,userCharacterList 回传空数组。
|
||||
# 想复刻真机的"组队 5 只各 +1 级"就改成 1(真机样本 4455->4456 等)。
|
||||
#
|
||||
# ★ 注意:设成 0 时 userCharacterList 差集为空 -> 该节点不回传,
|
||||
# 这是**预期的**,不是错误;validate_before_send 也只在你把增量设成非 0
|
||||
# 却又没产出条目时才告警。
|
||||
PLAY_CHARACTER_EXP_GAIN = 0
|
||||
|
||||
# --- ★ 关于 userGetPointList / userLoginBonusList(留空即可)---
|
||||
#
|
||||
# 这两个节点是**登录奖励发放**才产生的,普通游玩结算不需要回传:
|
||||
#
|
||||
# userGetPointList(里程/点数明细)
|
||||
# 真机 39 次 UpsertUserAll 样本里,**25 次为空(64%)**;
|
||||
# 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。
|
||||
# 留空 => 服务端不累加 point,账户点数保持不变。
|
||||
#
|
||||
# userLoginBonusList(签到奖励)
|
||||
# 同理,只有本次登录触发新签到才回传那一条(isCurrent=true)。
|
||||
# 留空 => isNewLoginBonusList 也是空串。
|
||||
#
|
||||
# ★ 连带影响(正常现象):留空后 userData.point 保持基线值不变。
|
||||
#
|
||||
# 源码参考(如需还原发放行为):
|
||||
# Manager/UserData.cs:57-62 enum MaiMileGetKind { Mission=1, FriendBonus=2, Present=3 }
|
||||
# Manager/UserData.cs:352-367 AddMissionPointLog / AddFriendBonusPointLog
|
||||
|
||||
# --- 店铺库存查询清单(不填则跳过该节点)---
|
||||
PLAY_SHOP_ITEM_IDS = []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 五、UpsertUserAll 安全闸门
|
||||
# =============================================================================
|
||||
#
|
||||
# ★ 这是**唯一有副作用**的接口:会把本局成绩真实写进服务器,不可撤销。
|
||||
# 保持 False(默认),调用时必须显式传 confirm=True 才真正发包。
|
||||
# 改成 True 会永久放行(不建议)。
|
||||
UPSERT_CONFIRM = False
|
||||
|
||||
# 真实发包前的冷却等待(秒)
|
||||
#
|
||||
# ★ 语义:**每次**真实发送 UpsertUserAll 之前,都固定等待这么多秒(含首次)。
|
||||
# 目的:让上报节奏贴近真机 —— 真机从登录到结算上报约 80 秒,
|
||||
# 不会登录后立刻上报(source/2026-10-05.log:25 登录 → :123 上报,间隔 81 秒)。
|
||||
#
|
||||
# ★ 只作用于**真实发送**(confirm=True);dry_run 不等待。
|
||||
# ★ 冷却发生在「发包前自检」之后,所以自检有问题时会先看到告警,不必白等。
|
||||
# ★ 调试时想跳过等待:把这里改成 0,或调用时传 cooldown_sec=0。
|
||||
UPSERT_SEND_COOLDOWN_SEC = 60
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 六、日志级别
|
||||
# =============================================================================
|
||||
#
|
||||
# "info" 只输出 api 脚本的运行情况与结果:
|
||||
# === XxxApi 开始运行 ===
|
||||
# === XxxApi 运行结束 ===
|
||||
#
|
||||
# "debug" 在上面基础上,额外输出向 api 发送的数据与 api 返回的数据:
|
||||
# → 请求 XxxApi URL / 请求头 / 请求体 JSON 全文
|
||||
# ← 响应 XxxApi 状态码 / 耗时 / 响应头 / 响应体 JSON 全文
|
||||
#
|
||||
LOG_LEVEL = "info"
|
||||
|
||||
# 单条日志里内嵌长文本的截断长度(0 = 不截断)
|
||||
LOG_VALUE_MAX = 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 导出
|
||||
# =============================================================================
|
||||
|
||||
__all__ = [
|
||||
# 网络与加密
|
||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", "MAI_ENCODING",
|
||||
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
|
||||
# 机台与店铺
|
||||
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
|
||||
"AUTHV1_WRITE_CONFIG",
|
||||
# 功能票
|
||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
||||
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
||||
# 扫码登录
|
||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", "QR_SIGN_KEY",
|
||||
"QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
||||
# 歌曲数据
|
||||
"PLAY_MUSIC_ID", "PLAY_MUSIC_LEVEL", "PLAY_TRACK_NO",
|
||||
"PLAY_VERSION_INT", "PLAY_VERSION_STR",
|
||||
"PLAY_ACHIEVEMENT", "PLAY_DELUXSCORE", "PLAY_SCORE_RANK", "PLAY_IS_CLEAR",
|
||||
"PLAY_MAX_COMBO", "PLAY_TOTAL_COMBO", "PLAY_MAX_SYNC", "PLAY_TOTAL_SYNC",
|
||||
"PLAY_COMBO_STATUS", "PLAY_SYNC_STATUS", "PLAY_FAST_COUNT", "PLAY_LATE_COUNT",
|
||||
"PLAY_JUDGE_COUNTS",
|
||||
"PLAY_BEFORE_RATING", "PLAY_AFTER_RATING",
|
||||
"PLAY_BEFORE_GRADE", "PLAY_AFTER_GRADE", "PLAY_AFTER_GRADE_RANK",
|
||||
"PLAY_BEFORE_DELUX_RATING", "PLAY_AFTER_DELUX_RATING",
|
||||
"PLAY_CATEGORY_INDEX", "PLAY_MUSIC_INDEX", "PLAY_EXTRA_FLAG",
|
||||
"PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE",
|
||||
"PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE",
|
||||
"PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM",
|
||||
"PLAY_USER_PLAY_COUNT_GAIN",
|
||||
"PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN",
|
||||
"PLAY_SHOP_ITEM_IDS",
|
||||
# 闸门
|
||||
"UPSERT_CONFIRM", "UPSERT_SEND_COOLDOWN_SEC",
|
||||
# 日志
|
||||
"LOG_LEVEL", "LOG_VALUE_MAX",
|
||||
]
|
||||
@@ -0,0 +1,703 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
|
||||
================================================================================
|
||||
|
||||
【DLI 是什么】
|
||||
DLI = **Download Instruction**(配信指示書)。
|
||||
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
|
||||
|
||||
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||
|
||||
服务器回:
|
||||
|
||||
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
|
||||
|
||||
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
|
||||
|
||||
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
||||
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
||||
INSTALL2=https://.../SDGB_A005_..._0.opt
|
||||
|
||||
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
|
||||
只是文件名,不是下载项。
|
||||
|
||||
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
|
||||
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
|
||||
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
|
||||
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
|
||||
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
|
||||
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
|
||||
日志 "Net deliver param - dli uri : %s." @0x63c3f8
|
||||
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
|
||||
|
||||
【实测结论】(2026-10-06)
|
||||
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
|
||||
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
|
||||
title_ver<=1.54 -> uri = null (服务器不认这个版本)
|
||||
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
|
||||
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
|
||||
|
||||
【用法】
|
||||
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
|
||||
python netdelivery.py A63E01E9580 # 指定 Keychip
|
||||
python netdelivery.py --list # 只列清单,不下载
|
||||
python netdelivery.py --all # 全下(不交互)
|
||||
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||
python netdelivery.py --out D:\SDGB2026\update
|
||||
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
||||
|
||||
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
|
||||
from authv1 import (
|
||||
DEFAULT_HOST,
|
||||
DEFAULT_TIMEOUT,
|
||||
AllnetError,
|
||||
delivery_instruction,
|
||||
parse_keychip,
|
||||
)
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# 下载器用的 UA(实测有效)
|
||||
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
|
||||
|
||||
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
|
||||
CHUNK = 1 << 16
|
||||
PROBE_TIMEOUT = 30
|
||||
DOWNLOAD_TIMEOUT = 60
|
||||
|
||||
# 包类型判定(按扩展名)
|
||||
KIND_BY_EXT = {
|
||||
".app": "app",
|
||||
".opt": "opt",
|
||||
".pack": "pack",
|
||||
}
|
||||
|
||||
KIND_TEXT = {
|
||||
"app": "本体/补丁",
|
||||
"opt": "选项包",
|
||||
"pack": "资源包",
|
||||
"?": "未知",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# HTTP
|
||||
# =============================================================================
|
||||
|
||||
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
|
||||
"""
|
||||
GET,顺带处理 CDN 证书问题。
|
||||
|
||||
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
|
||||
(只影响这一次请求,不改变全局设置)。
|
||||
"""
|
||||
merged = dict(DOWNLOAD_HEADERS)
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
try:
|
||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||
verify=not insecure)
|
||||
except requests.exceptions.SSLError:
|
||||
if insecure:
|
||||
raise
|
||||
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||
verify=False)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 配信指示書解析
|
||||
# =============================================================================
|
||||
|
||||
def parse_instruction(text):
|
||||
"""
|
||||
解析配信指示書 INI。
|
||||
|
||||
返回 (common, installs):
|
||||
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
|
||||
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
|
||||
"""
|
||||
common = {}
|
||||
installs = []
|
||||
section = ""
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith(";") or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1].strip().upper()
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip().upper()
|
||||
value = value.strip().strip('"')
|
||||
if section in ("", "COMMON"):
|
||||
common.setdefault(key, value)
|
||||
if key.startswith("INSTALL") and value:
|
||||
installs.append({"section": section or "COMMON", "key": key, "url": value})
|
||||
return common, installs
|
||||
|
||||
|
||||
def guess_kind(url):
|
||||
"""按扩展名猜包类型。"""
|
||||
name = url.split("?")[0].rsplit("/", 1)[-1]
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
return KIND_BY_EXT.get(ext, "?")
|
||||
|
||||
|
||||
def package_name(url):
|
||||
return url.split("?")[0].rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def human_size(num):
|
||||
if num is None:
|
||||
return "?"
|
||||
value = float(num)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if value < 1024 or unit == "TB":
|
||||
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
|
||||
value /= 1024.0
|
||||
return "?"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 采集:DLI -> 指示書 -> 包清单
|
||||
# =============================================================================
|
||||
|
||||
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
|
||||
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
|
||||
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||
kv = {}
|
||||
for part in text.strip().split("&"):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
kv[k.strip()] = v
|
||||
result = kv.get("result", "")
|
||||
if result != "1":
|
||||
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
|
||||
|
||||
uri = kv.get("uri", "").strip()
|
||||
if uri in ("", "null"):
|
||||
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
|
||||
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
|
||||
|
||||
parts = uri.split("|")
|
||||
patch_url = parts[0].strip() if len(parts) > 0 else ""
|
||||
option_url = parts[1].strip() if len(parts) > 1 else ""
|
||||
for bad in ("null", "none"):
|
||||
if patch_url.lower() == bad:
|
||||
patch_url = ""
|
||||
if option_url.lower() == bad:
|
||||
option_url = ""
|
||||
return patch_url, option_url, text.strip()
|
||||
|
||||
|
||||
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
|
||||
"""
|
||||
走完整条链路,返回 (items, meta)。
|
||||
|
||||
items 每一项:
|
||||
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
|
||||
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
|
||||
"""
|
||||
patch_url, option_url, dli_raw = fetch_instruction_urls(
|
||||
client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||
|
||||
items = []
|
||||
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
|
||||
"instructions": [], "infos": {}}
|
||||
|
||||
for info_url in (patch_url, option_url):
|
||||
if not info_url:
|
||||
continue
|
||||
info_name = package_name(info_url)
|
||||
resp = None
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
resp = http_get(info_url, timeout=timeout, insecure=insecure)
|
||||
if resp.status_code < 500:
|
||||
break
|
||||
except Exception:
|
||||
resp = None
|
||||
time.sleep(1.5 * attempt)
|
||||
if resp is None:
|
||||
meta["instructions"].append({"url": info_url,
|
||||
"error": "拉取失败(已重试 3 次)"})
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
meta["instructions"].append({"url": info_url, "status": resp.status_code,
|
||||
"error": "HTTP %d" % resp.status_code})
|
||||
continue
|
||||
text = resp.content.decode("utf-8", "replace")
|
||||
common, installs = parse_instruction(text)
|
||||
meta["instructions"].append({"url": info_url, "status": 200,
|
||||
"name": info_name, "common": common})
|
||||
meta["infos"][info_name] = text
|
||||
|
||||
seen = set()
|
||||
for entry in installs:
|
||||
if entry["url"] in seen:
|
||||
continue
|
||||
seen.add(entry["url"])
|
||||
items.append({
|
||||
"kind": guess_kind(entry["url"]),
|
||||
"section": entry["section"],
|
||||
"key": entry["key"],
|
||||
"url": entry["url"],
|
||||
"name": package_name(entry["url"]),
|
||||
"size": None,
|
||||
"note": "",
|
||||
"info_name": info_name,
|
||||
})
|
||||
|
||||
for i, item in enumerate(items, 1):
|
||||
item["idx"] = i
|
||||
return items, meta
|
||||
|
||||
|
||||
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
|
||||
"""探每个包的大小 / 可用性(就地写回 item)。"""
|
||||
for item in items:
|
||||
for attempt in range(1, 3):
|
||||
try:
|
||||
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
|
||||
stream=True, timeout=timeout, insecure=insecure)
|
||||
status = resp.status_code
|
||||
content_range = resp.headers.get("Content-Range", "")
|
||||
content_length = resp.headers.get("Content-Length")
|
||||
resp.close()
|
||||
if status == 206 and "/" in content_range:
|
||||
item["size"] = int(content_range.rsplit("/", 1)[-1])
|
||||
item["note"] = ""
|
||||
elif status == 200 and content_length and content_length.isdigit():
|
||||
item["size"] = int(content_length)
|
||||
item["note"] = ""
|
||||
elif status in (404, 504):
|
||||
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
|
||||
else:
|
||||
item["note"] = "HTTP %d" % status
|
||||
break
|
||||
except Exception as exc:
|
||||
item["note"] = "探测失败:%s" % exc
|
||||
if attempt < 2:
|
||||
time.sleep(1.5)
|
||||
continue
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 展示与选择
|
||||
# =============================================================================
|
||||
|
||||
def print_packages(items):
|
||||
if not items:
|
||||
print(" (指示書里没有任何 INSTALL 项)")
|
||||
return
|
||||
print("")
|
||||
print(" 序号 类型 大小 来源段/键 文件名")
|
||||
print(" " + "-" * 92)
|
||||
for item in items:
|
||||
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
|
||||
print(" [%2d] %-4s %-12s %-20s %s" % (
|
||||
item["idx"], item["kind"], size,
|
||||
item["section"] + "/" + item["key"], item["name"]))
|
||||
if item["note"] and item["size"] is None:
|
||||
print(" %s" % item["note"])
|
||||
|
||||
|
||||
def prompt_selection(items):
|
||||
"""
|
||||
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
|
||||
"""
|
||||
print("")
|
||||
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
|
||||
print(" a = 全选 n = 都不选 q = 退出")
|
||||
while True:
|
||||
try:
|
||||
raw = input(" 请选择要下载的包 > ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("")
|
||||
return None
|
||||
if not raw:
|
||||
continue
|
||||
low = raw.lower()
|
||||
if low in ("q", "quit", "exit"):
|
||||
return None
|
||||
if low in ("a", "all"):
|
||||
return list(range(len(items)))
|
||||
if low in ("n", "none"):
|
||||
return []
|
||||
|
||||
picked, bad = [], []
|
||||
for token in re.split(r"[,,、\s]+", raw):
|
||||
if not token:
|
||||
continue
|
||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||
if span:
|
||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||
for i in range(low_i, high_i + 1):
|
||||
if 1 <= i <= len(items):
|
||||
picked.append(i - 1)
|
||||
else:
|
||||
bad.append(str(i))
|
||||
continue
|
||||
if token.isdigit():
|
||||
i = int(token)
|
||||
if 1 <= i <= len(items):
|
||||
picked.append(i - 1)
|
||||
else:
|
||||
bad.append(token)
|
||||
else:
|
||||
bad.append(token)
|
||||
|
||||
if bad:
|
||||
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
|
||||
continue
|
||||
|
||||
ordered, seen = [], set()
|
||||
for i in picked:
|
||||
if i not in seen:
|
||||
seen.add(i)
|
||||
ordered.append(i)
|
||||
if not ordered:
|
||||
print(" [x] 没有选中任何项")
|
||||
continue
|
||||
return ordered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 下载
|
||||
# =============================================================================
|
||||
|
||||
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
|
||||
"""
|
||||
带断点续传的下载。返回 (ok, 说明)。
|
||||
|
||||
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
|
||||
"""
|
||||
part = dest + ".part"
|
||||
total = None
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
done = os.path.getsize(part) if os.path.exists(part) else 0
|
||||
headers = {}
|
||||
if done:
|
||||
headers["Range"] = "bytes=%d-" % done
|
||||
try:
|
||||
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
|
||||
insecure=insecure)
|
||||
except Exception as exc:
|
||||
if attempt == retries:
|
||||
return False, "连接失败:%s" % exc
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
if resp.status_code not in (200, 206):
|
||||
resp.close()
|
||||
if resp.status_code in (404, 504):
|
||||
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
|
||||
if attempt == retries:
|
||||
return False, "HTTP %d" % resp.status_code
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
# 服务器忽略 Range 直接给了全量 -> 从头写
|
||||
if done and resp.status_code == 200:
|
||||
done = 0
|
||||
|
||||
content_range = resp.headers.get("Content-Range", "")
|
||||
if "/" in content_range:
|
||||
try:
|
||||
total = int(content_range.rsplit("/", 1)[-1])
|
||||
except ValueError:
|
||||
total = None
|
||||
elif resp.headers.get("Content-Length", "").isdigit():
|
||||
total = done + int(resp.headers["Content-Length"])
|
||||
|
||||
mode = "ab" if done else "wb"
|
||||
started = time.time()
|
||||
written = done
|
||||
last_tick = 0.0
|
||||
last_shown = -1
|
||||
try:
|
||||
with open(part, mode) as fp:
|
||||
for chunk in resp.iter_content(CHUNK):
|
||||
if not chunk:
|
||||
continue
|
||||
fp.write(chunk)
|
||||
written += len(chunk)
|
||||
now = time.time()
|
||||
if now - last_tick >= 0.3:
|
||||
last_tick = now
|
||||
last_shown = written
|
||||
show_progress(written, total, now - started)
|
||||
except Exception as exc:
|
||||
resp.close()
|
||||
if attempt == retries:
|
||||
return False, "写入中断:%s" % exc
|
||||
time.sleep(2)
|
||||
continue
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
if written == last_shown:
|
||||
sys.stdout.write("\n") # 收尾换行,避免重复打一行
|
||||
else:
|
||||
show_progress(written, total, max(time.time() - started, 0.001), final=True)
|
||||
|
||||
if total is not None and written != total:
|
||||
if attempt == retries:
|
||||
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
if os.path.exists(dest):
|
||||
os.remove(dest)
|
||||
os.replace(part, dest)
|
||||
return True, "%s(%s)" % (human_size(written), dest)
|
||||
|
||||
return False, "重试 %d 次仍失败" % retries
|
||||
|
||||
|
||||
def show_progress(done, total, elapsed, final=False):
|
||||
speed = done / elapsed if elapsed > 0 else 0
|
||||
if total:
|
||||
percent = done * 100.0 / total
|
||||
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
|
||||
human_size(total), human_size(speed))
|
||||
else:
|
||||
line = " %s %s/s" % (human_size(done), human_size(speed))
|
||||
sys.stdout.write("\r" + line + ("\n" if final else ""))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(
|
||||
prog="netdelivery.py",
|
||||
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=(
|
||||
"示例:\n"
|
||||
" python netdelivery.py # 查完让你多选\n"
|
||||
" python netdelivery.py --list # 只列清单\n"
|
||||
" python netdelivery.py --all # 全下\n"
|
||||
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
|
||||
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
|
||||
),
|
||||
)
|
||||
p.add_argument("keychip", nargs="?",
|
||||
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
|
||||
p.add_argument("--ver", default=None,
|
||||
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||
p.add_argument("--host", default=DEFAULT_HOST,
|
||||
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||
p.add_argument("--out", default=DEFAULT_OUT_DIR,
|
||||
help="下载目录(默认 <脚本目录>/download)")
|
||||
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
|
||||
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
|
||||
p.add_argument("--all", action="store_true", help="全选(不交互)")
|
||||
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
|
||||
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
|
||||
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
|
||||
return p
|
||||
|
||||
|
||||
def resolve_keychip(cli_value):
|
||||
if cli_value:
|
||||
return cli_value
|
||||
try:
|
||||
import config # noqa: F401
|
||||
value = getattr(config, "KEYCHIP_ID", "")
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_title_ver(cli_value):
|
||||
if cli_value:
|
||||
return str(cli_value)
|
||||
try:
|
||||
import config # noqa: F401
|
||||
return str(getattr(config, "MAI_ENCODING", "1.55"))
|
||||
except Exception:
|
||||
return "1.55"
|
||||
|
||||
|
||||
def parse_only(text, count):
|
||||
"""把 --only 的文本解析成 0 基下标列表。"""
|
||||
picked, seen = [], set()
|
||||
for token in re.split(r"[,,、\s]+", text or ""):
|
||||
if not token:
|
||||
continue
|
||||
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||
if span:
|
||||
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||
candidates = range(low_i, high_i + 1)
|
||||
elif token.isdigit():
|
||||
candidates = [int(token)]
|
||||
else:
|
||||
raise ValueError("--only 里有无效项:%r" % token)
|
||||
for i in candidates:
|
||||
if not (1 <= i <= count):
|
||||
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
|
||||
if i - 1 not in seen:
|
||||
seen.add(i - 1)
|
||||
picked.append(i - 1)
|
||||
return picked
|
||||
|
||||
|
||||
def save_instructions(meta, out_dir):
|
||||
"""把配信指示書原样存一份,便于事后核对。"""
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
saved = []
|
||||
for name, text in meta["infos"].items():
|
||||
path = os.path.join(out_dir, name)
|
||||
with open(path, "w", encoding="utf-8", newline="") as fp:
|
||||
fp.write(text)
|
||||
saved.append(path)
|
||||
return saved
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
try:
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
args = build_parser().parse_args(argv)
|
||||
title_ver = resolve_title_ver(args.ver)
|
||||
|
||||
raw_keychip = resolve_keychip(args.keychip)
|
||||
if not raw_keychip:
|
||||
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
|
||||
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
|
||||
return 2
|
||||
try:
|
||||
client_id = parse_keychip(raw_keychip)
|
||||
except ValueError as exc:
|
||||
print("Keychip 解析失败:%s" % exc)
|
||||
return 2
|
||||
|
||||
print("=== netdelivery (ALL.Net DLI) ===")
|
||||
print(" clientId : %s" % client_id)
|
||||
print(" title_ver : %s" % title_ver)
|
||||
print(" server : %s" % args.host)
|
||||
print(" 正在查询配信指示書 ...")
|
||||
|
||||
try:
|
||||
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
|
||||
timeout=args.timeout, insecure=args.insecure)
|
||||
except (AllnetError, ValueError) as exc:
|
||||
print("查询失败:%s" % exc)
|
||||
return 1
|
||||
|
||||
for entry in meta["instructions"]:
|
||||
if entry.get("status") == 200:
|
||||
common = entry.get("common", {})
|
||||
print(" 指示書 %s" % entry["name"])
|
||||
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
|
||||
"RELEASE_TIME", "RELEASE_TYPE"):
|
||||
if key in common:
|
||||
print(" %-14s %s" % (key + ":", common[key]))
|
||||
else:
|
||||
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
|
||||
|
||||
if not items:
|
||||
print("没有可下载的包。")
|
||||
return 0
|
||||
|
||||
if not args.no_probe:
|
||||
print(" 正在探测包大小 ...")
|
||||
probe_all(items, insecure=args.insecure)
|
||||
|
||||
print_packages(items)
|
||||
|
||||
if args.list:
|
||||
print("")
|
||||
print(" 直链:")
|
||||
for item in items:
|
||||
print(" [%2d] %s" % (item["idx"], item["url"]))
|
||||
return 0
|
||||
|
||||
# ---- 决定下哪些 ----
|
||||
if args.only:
|
||||
try:
|
||||
chosen = parse_only(args.only, len(items))
|
||||
except ValueError as exc:
|
||||
print("参数错误:%s" % exc)
|
||||
return 2
|
||||
elif args.all:
|
||||
chosen = list(range(len(items)))
|
||||
elif not sys.stdin or not sys.stdin.isatty():
|
||||
print("")
|
||||
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
|
||||
return 0
|
||||
else:
|
||||
chosen = prompt_selection(items)
|
||||
if chosen is None:
|
||||
print(" 已取消。")
|
||||
return 0
|
||||
if not chosen:
|
||||
print(" 没有选中任何项,退出。")
|
||||
return 0
|
||||
|
||||
out_dir = args.out
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for path in save_instructions(meta, out_dir):
|
||||
print(" [指示書] %s" % path)
|
||||
|
||||
print("")
|
||||
print(" 下载目录:%s" % out_dir)
|
||||
failed = []
|
||||
for index in chosen:
|
||||
item = items[index]
|
||||
dest = os.path.join(out_dir, item["name"])
|
||||
print("")
|
||||
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
|
||||
item["name"], KIND_TEXT.get(item["kind"], "?")))
|
||||
if item["size"] is None and item["note"]:
|
||||
print(" 跳过:%s" % item["note"])
|
||||
failed.append((item, item["note"]))
|
||||
continue
|
||||
started = time.time()
|
||||
ok, message = download_file(item["url"], dest, insecure=args.insecure)
|
||||
if ok:
|
||||
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
|
||||
else:
|
||||
print(" 失败:%s" % message)
|
||||
failed.append((item, message))
|
||||
|
||||
print("")
|
||||
if failed:
|
||||
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
|
||||
for item, message in failed:
|
||||
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
|
||||
return 1
|
||||
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
|
||||
================================================================================
|
||||
|
||||
┌─ 源账号 A ─────────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
|
||||
│ 或:直接读 data/<userId>.json(离线复用) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
↓ 节点数据
|
||||
┌─ 目标账号 B ───────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
||||
│ 循环: │
|
||||
│ 等待 -> UpsertUserAll(一包曲目) │
|
||||
│ -> UserLogout(type=1) │
|
||||
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
|
||||
│ 收尾:最后一包登出后结束(不再续关) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
|
||||
--------------------------------------------------------------------------------
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
||||
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
||||
|
||||
★ 但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
|
||||
所以本脚本的分批器会**主动避开 2 首包**:
|
||||
|
||||
10 首 -> [4, 4, 2] -> [4, 3, 3]
|
||||
6 首 -> [4, 2] -> [3, 3]
|
||||
2 首 -> [2] -> [1, 1]
|
||||
|
||||
★ 每包都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
||||
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么需要「续关登录」
|
||||
--------------------------------------------------------------------------------
|
||||
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
|
||||
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
|
||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||
真机走的就是「续关」:
|
||||
|
||||
Process/ContinueProcess.cs:403
|
||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||
|
||||
对应本工程:UserLogin(user_id, token, is_continue=True)
|
||||
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
||||
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
||||
|
||||
★ 登出必须用 **type=1(LogoutType.Logout)**。
|
||||
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
|
||||
{"userId":...,"type":1,"delayLog":{...}}
|
||||
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ playlog 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
|
||||
所以本脚本**从曲目记录反推合成**:
|
||||
|
||||
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
|
||||
───────────────────────────── ─────────────────────────
|
||||
musicId musicId
|
||||
level level
|
||||
achievement achievement
|
||||
deluxscoreMax deluxscore
|
||||
scoreRank scoreRank / isClear
|
||||
comboStatus comboStatus
|
||||
syncStatus syncStatus
|
||||
其余字段 取自 config.PLAY_*(合理默认值)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
||||
|
||||
# ★ 真实上传
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
||||
|
||||
# 只看计划,不发包
|
||||
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
||||
|
||||
# 常用开关
|
||||
--limit N 只迁前 N 首(试跑用)
|
||||
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
|
||||
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
|
||||
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
||||
--no-continue 不续关(只发一包,调试用)
|
||||
|
||||
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
|
||||
★ 二维码**有效期 10 分钟**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_error,
|
||||
log_info,
|
||||
log_warning,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
GetNeedUserData,
|
||||
normalize_snapshot,
|
||||
NODE_ORDER,
|
||||
NODE_TYPES,
|
||||
ARRAY_TO_BITS,
|
||||
DIFF_PRIMARY_KEYS,
|
||||
PlayData,
|
||||
build_user_playlog,
|
||||
build_user_game_playlog,
|
||||
UpsertUserAll,
|
||||
ConfirmRequiredError,
|
||||
Ping,
|
||||
load_user_data,
|
||||
list_saved_user_ids,
|
||||
DATA_DIR,
|
||||
)
|
||||
from config import PLACE_ID, REGION_ID
|
||||
|
||||
#: 真机一局最多 4 首
|
||||
MAX_TRACKS_PER_CREDIT = 4
|
||||
|
||||
#: ★ 被服务端禁止的每包曲目数(实测:**恰好 2 首会被拒**)
|
||||
#: 1 / 3 / 4 首都正常,只有 2 首不行 —— 所以分批时要避开它。
|
||||
FORBIDDEN_TRACK_COUNTS = (2,)
|
||||
|
||||
#: 允许的 --per-packet 取值
|
||||
ALLOWED_PER_PACKET = (1, 3, 4)
|
||||
|
||||
|
||||
def split_batches(total, per):
|
||||
"""
|
||||
把 total 首切成「每包 <= per」的批次,**并且不产生被禁止的 2 首包**。
|
||||
|
||||
做法:先贪心按 per 切,再修正大小为 2 的批次:
|
||||
|
||||
① 前一批 >= 4 -> 从它借 1 首过来(前一批变 >=3,本批变 3)
|
||||
② 借不到(前一批只有 3,或自己就是第一批)-> 把这个 2 拆成 1 + 1
|
||||
|
||||
★ 只借位是不够的 —— 前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
|
||||
|
||||
例:
|
||||
10 首 per=4 -> [4, 4, 2] -> [4, 3, 3]
|
||||
6 首 per=4 -> [4, 2] -> [3, 3]
|
||||
2 首 per=4 -> [2] -> [1, 1]
|
||||
5 首 per=3 -> [3, 2] -> [3, 1, 1]
|
||||
8 首 per=3 -> [3, 3, 2] -> [3, 3, 1, 1]
|
||||
5 首 per=4 -> [4, 1] (本来就合法,不动)
|
||||
"""
|
||||
if total <= 0:
|
||||
return []
|
||||
per = max(1, int(per))
|
||||
out = []
|
||||
left = total
|
||||
while left > 0:
|
||||
n = min(per, left)
|
||||
out.append(n)
|
||||
left -= n
|
||||
|
||||
i = 0
|
||||
while i < len(out):
|
||||
if out[i] not in FORBIDDEN_TRACK_COUNTS:
|
||||
i += 1
|
||||
continue
|
||||
# ① 前一批 >= 4 才能借(借完前一批 >= 3,本批 = 3)
|
||||
if i > 0 and out[i - 1] >= 4:
|
||||
out[i - 1] -= 1
|
||||
out[i] += 1
|
||||
i += 1
|
||||
continue
|
||||
# ② 借不到 -> 把这个 2 拆成 1 + 1
|
||||
out[i] = 1
|
||||
out.insert(i + 1, 1)
|
||||
i += 2
|
||||
return out
|
||||
|
||||
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
|
||||
IDENTITY_KEYS = ("userName", "accessCode")
|
||||
|
||||
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
|
||||
SKIP_NODES = (
|
||||
"userMusicDetailList", # 单独分批
|
||||
"userGamePlaylogList", # 由本脚本按曲目合成
|
||||
"user2pPlaylog", # 2P 记录,迁移无意义
|
||||
"userGetPointList", # 里程/奖励,迁移会凭空发点
|
||||
"userTradeItemList", # 无 Get 来源
|
||||
"userFriendSeasonRankingList", # 无 Get 来源
|
||||
)
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
|
||||
"plan": False, "debug": False, "limit": 0,
|
||||
"per_packet": MAX_TRACKS_PER_CREDIT,
|
||||
"clone_identity": False, "no_continue": False,
|
||||
"wait": 5.0}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--plan":
|
||||
opts["plan"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--clone-identity":
|
||||
opts["clone_identity"] = True
|
||||
elif a == "--no-continue":
|
||||
opts["no_continue"] = True
|
||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
|
||||
"--per-packet", "--wait") and i + 1 < len(argv):
|
||||
key = a[2:].replace("-", "_")
|
||||
opts[key] = (float(argv[i + 1]) if key == "wait"
|
||||
else int(argv[i + 1]) if key in ("limit", "per_packet")
|
||||
else argv[i + 1])
|
||||
i += 1
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 工具
|
||||
# =============================================================================
|
||||
|
||||
def _bits_for(node, items, base_items):
|
||||
"""
|
||||
按目标账号 B 的基线算 isNew* 位串:
|
||||
B 里没有该条目 -> "1"(新增)
|
||||
B 里已有 -> "0"(更新)
|
||||
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
|
||||
"""
|
||||
keys = DIFF_PRIMARY_KEYS.get(node)
|
||||
if not keys:
|
||||
return None
|
||||
have = set()
|
||||
for b in (base_items or []):
|
||||
if isinstance(b, dict):
|
||||
have.add(tuple(str(b.get(k)) for k in keys))
|
||||
return "".join(
|
||||
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
|
||||
for it in (items or []) if isinstance(it, dict)
|
||||
)
|
||||
|
||||
|
||||
def play_from_detail(detail, base_play=None):
|
||||
"""
|
||||
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
|
||||
|
||||
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
|
||||
"""
|
||||
p = PlayData() if base_play is None else base_play
|
||||
p.music_id = int(detail.get("musicId", p.music_id) or 0)
|
||||
p.music_level = int(detail.get("level", p.music_level) or 0)
|
||||
p.achievement = int(detail.get("achievement", p.achievement) or 0)
|
||||
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
|
||||
rank = int(detail.get("scoreRank", 0) or 0)
|
||||
p.score_rank = rank
|
||||
p.is_clear = rank > 0
|
||||
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
|
||||
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
|
||||
return p
|
||||
|
||||
|
||||
def collect_nodes(src_nodes, base_nodes, clone_identity):
|
||||
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
|
||||
out = {}
|
||||
for node in NODE_ORDER:
|
||||
if node in SKIP_NODES:
|
||||
continue
|
||||
if node.startswith("isNew"):
|
||||
continue # 位串单独算
|
||||
kind = NODE_TYPES.get(node)
|
||||
if kind not in ("singleton", "array", "object"):
|
||||
continue
|
||||
val = src_nodes.get(node)
|
||||
if val in (None, [], {}):
|
||||
continue
|
||||
out[node] = json.loads(json.dumps(val)) # 深拷贝
|
||||
# 身份字段:默认保留 B 自己的
|
||||
if not clone_identity and out.get("userData"):
|
||||
for item in out["userData"]:
|
||||
if isinstance(item, dict):
|
||||
for k in IDENTITY_KEYS:
|
||||
item.pop(k, None)
|
||||
# 位串:按 B 的基线算
|
||||
for arr, bits in ARRAY_TO_BITS.items():
|
||||
if arr not in out:
|
||||
continue
|
||||
b = _bits_for(arr, out[arr], base_nodes.get(arr))
|
||||
if b:
|
||||
out[bits] = b
|
||||
return out
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 主流程
|
||||
# =============================================================================
|
||||
|
||||
def fetch_source_by_qr(qr):
|
||||
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
|
||||
log_info("=== 源账号:联网抓取 ===")
|
||||
uid, token = ParseQRCode(qr)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
try:
|
||||
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
finally:
|
||||
try:
|
||||
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_warning("源账号登出失败:" + str(exc))
|
||||
return raw
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts["debug"]:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
if opts["per_packet"] not in ALLOWED_PER_PACKET:
|
||||
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
|
||||
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
|
||||
return 1
|
||||
|
||||
setup_logging("reincarnate")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---------------- 1. 源数据 ----------------
|
||||
if opts["src_json"]:
|
||||
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
|
||||
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
|
||||
else:
|
||||
src_raw = fetch_source_by_qr(opts["src_qr"])
|
||||
|
||||
src_nodes = normalize_snapshot(src_raw)
|
||||
musics = src_nodes.get("userMusicDetailList") or []
|
||||
if opts["limit"] > 0:
|
||||
musics = musics[:opts["limit"]]
|
||||
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
|
||||
|
||||
if not musics:
|
||||
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
|
||||
return 4
|
||||
|
||||
# ---------------- 2. 目标账号 ----------------
|
||||
Ping()
|
||||
uid, token = ParseQRCode(opts["dst_qr"])
|
||||
session.update(user_id=uid, token=token)
|
||||
preview = GetUserPreview(uid, token)
|
||||
if preview.isLogin:
|
||||
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||
base_nodes = normalize_snapshot(base_raw)
|
||||
log_info("目标账号基线已抓取")
|
||||
|
||||
# ---------------- 3. 分批 ----------------
|
||||
per = int(opts["per_packet"])
|
||||
sizes = split_batches(len(musics), per)
|
||||
batches = []
|
||||
_pos = 0
|
||||
for _n in sizes:
|
||||
batches.append(musics[_pos:_pos + _n])
|
||||
_pos += _n
|
||||
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
|
||||
+ " 包,每包 " + "/".join(str(n) for n in sizes[:12])
|
||||
+ ("..." if len(sizes) > 12 else "") + " 首")
|
||||
log_info(" ★ 已避开 2 首包(实测恰好 2 首会被服务端拒绝;1/3/4 首都正常)")
|
||||
|
||||
if opts["plan"]:
|
||||
for i, b in enumerate(batches):
|
||||
log_info(" 第 " + str(i + 1) + " 包(" + str(len(b)) + " 首):" + ", ".join(
|
||||
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
|
||||
log_info("--plan:只列计划,不发包")
|
||||
return 0
|
||||
|
||||
# 第一包才带非曲目节点(整账号克隆)
|
||||
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
|
||||
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
|
||||
+ " 个将在第 1 包上传")
|
||||
|
||||
# ---------------- 4. 逐包上传 ----------------
|
||||
# ★ 流程(按使用方口径):
|
||||
# 登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
|
||||
#
|
||||
# ★ 登出必须用 type=1(LogoutType.Logout)。
|
||||
# 真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
|
||||
# 续关场景下用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义。
|
||||
sent = 0
|
||||
for idx, batch in enumerate(batches):
|
||||
log_info("")
|
||||
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
|
||||
+ " 包(" + str(len(batch)) + " 首)==========")
|
||||
|
||||
# ---- 等待 ----
|
||||
if opts["wait"] > 0:
|
||||
log_info("上传前等待 " + str(opts["wait"]) + " 秒 ...")
|
||||
time.sleep(opts["wait"])
|
||||
|
||||
nodes = {}
|
||||
if idx == 0:
|
||||
nodes.update(json.loads(json.dumps(shared_nodes)))
|
||||
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
|
||||
b = _bits_for("userMusicDetailList", batch,
|
||||
base_nodes.get("userMusicDetailList"))
|
||||
if b:
|
||||
nodes["isNewMusicDetailList"] = b
|
||||
log_info(" isNewMusicDetailList = " + b)
|
||||
|
||||
# 合成 playlog(每首一条)与 game playlog
|
||||
playlogs = []
|
||||
game_playlogs = []
|
||||
for d in batch:
|
||||
p = play_from_detail(d)
|
||||
playlogs.append(build_user_playlog(
|
||||
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
|
||||
game_playlogs.append(build_user_game_playlog(
|
||||
login.loginId, login.loginDateTime, p))
|
||||
nodes["userGamePlaylogList"] = game_playlogs
|
||||
|
||||
UpsertUserAll(
|
||||
uid, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
snapshot=base_raw,
|
||||
auto_characters=False,
|
||||
play=play_from_detail(batch[0]),
|
||||
extra_data=nodes,
|
||||
user_playlog_list=playlogs,
|
||||
pre_new_item_list=False,
|
||||
verify_landing=False,
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
sent += 1
|
||||
|
||||
# ---- ★ 登出(type=1 Logout)----
|
||||
log_info("--- 登出(type=1 Logout)---")
|
||||
try:
|
||||
UserLogout(uid, token, login.loginDateTime,
|
||||
logout_type=LogoutType.Logout,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_warning("登出失败(继续下一包):" + str(exc))
|
||||
session["logged_in"] = False
|
||||
|
||||
# ---- ★ 续关登录:拿下一包的 loginId ----
|
||||
if idx < len(batches) - 1:
|
||||
if opts["no_continue"]:
|
||||
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
|
||||
break
|
||||
log_info("--- 续关登录(isContinue=true)---")
|
||||
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
|
||||
is_continue=True)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
if not opts["send"]:
|
||||
log_info("")
|
||||
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
|
||||
return 0
|
||||
|
||||
log_info("")
|
||||
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
|
||||
+ str(min(len(musics), sent * per)) + " 首")
|
||||
return 0
|
||||
|
||||
except ConfirmRequiredError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,30 @@
|
||||
# tools/ —— 独立小工具
|
||||
|
||||
这个目录放**与游戏协议无关的独立小工具**:纯计算、纯文件处理。
|
||||
它们**不联网**、**不碰游戏服务器**、也**不依赖 `api/` 与 `config.py`**。
|
||||
|
||||
## 约定
|
||||
|
||||
| 约定 | 说明 |
|
||||
|---|---|
|
||||
| 一个工具一个子目录 | `tools/<工具名>/` |
|
||||
| 自包含 | 工具之间不互相依赖,单独拷走也能跑 |
|
||||
| 纯本地 | 不联网、不写游戏服务器;最多读本机文件 |
|
||||
| 无第三方依赖 | 只用 Python 标准库(除非工具自己的 README 里写明) |
|
||||
| 登记 | 新增工具时,在下面的「已有工具」表里加一行 |
|
||||
|
||||
## 已有工具
|
||||
|
||||
| 工具 | 目录 | 干什么 |
|
||||
|---|---|---|
|
||||
| 谱面达成率计算器 | [`chart_achievement/`](chart_achievement/) | 按判定数算谱面达成率与 DX 分;也可由达成率倒算判定组合 |
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```bash
|
||||
# 进工具目录,直接 python 跑(各工具的参数见它自己的说明)
|
||||
cd tools/chart_achievement
|
||||
python chart_achievement.py --help
|
||||
```
|
||||
|
||||
---
|
||||
@@ -0,0 +1,201 @@
|
||||
# chart_achievement —— 舞萌DX 谱面达成率 / DX 分计算器
|
||||
|
||||
给定谱面(`.ma2`)和判定数,算出**达成率**与 **DX 分**;反过来,给定目标达成率,
|
||||
倒算出所有能打出该达成率的判定组合。
|
||||
|
||||
纯本地、纯标准库(不需要联网、不写任何游戏文件)。
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```powershell
|
||||
cd tools/chart_achievement
|
||||
python chart_achievement.py --help
|
||||
python chart_achievement.py # 不给参数 = 交互向导
|
||||
```
|
||||
|
||||
## 谱面从哪来(二选一)
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` |
|
||||
| `--game <目录> --music-id <ID>` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 |
|
||||
|
||||
> 🔎 **不知道 musicId / 定数?** [maimai.lxns.net/songs](https://maimai.lxns.net/songs)
|
||||
> 可以在线查询谱面信息(曲名、难度、定数等),查到 `musicId` 和难度后再回来跑脚本。
|
||||
|
||||
### 难度(--level)
|
||||
|
||||
难度用**游戏里规定的数字**表示,也就是谱面文件名后缀 `_00` ~ `_04` 的那个数字
|
||||
(对应游戏主数据 `musicDifficulty/MusicDifficulty00000X` 的 `<id>`):
|
||||
|
||||
| 数字 | 难度 | 谱面文件名 |
|
||||
| --- | --- | --- |
|
||||
| 0 | BASIC | `<id>_00.ma2` |
|
||||
| 1 | ADVANCED | `<id>_01.ma2` |
|
||||
| 2 | EXPERT | `<id>_02.ma2` |
|
||||
| 3 | MASTER | `<id>_03.ma2` |
|
||||
| 4 | RE:MASTER | `<id>_04.ma2` |
|
||||
|
||||
`--level 0` ~ `--level 4` 或 `--level BASIC` / `ADVANCED` / `EXPERT` / `MASTER` / `RE:MASTER`
|
||||
(大小写随意;`--difficulty` 是同一个参数的别名)都行,**默认 3(MASTER)**;
|
||||
不给时会在 stderr 提示用了默认值。
|
||||
|
||||
注意:这里的「等级」指**谱面难度**。谱面的数值等级(如 `12+`)是另一回事,
|
||||
由 `Music.xml` 自动读出,只用于显示。
|
||||
|
||||
## 正向:已知判定数 → 达成率 + DX 分
|
||||
|
||||
达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500),
|
||||
所以有两种给判定数的方式:
|
||||
|
||||
### ① 精确:按音符类别给(推荐,得到确切数值)
|
||||
|
||||
```powershell
|
||||
python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --tap 260/0/3 --break 6 # 3 = MASTER
|
||||
```
|
||||
|
||||
每个类别写 `CP/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略),
|
||||
**没给满的部分按 CP 算**:
|
||||
|
||||
| 参数 | 类别 | 权重 |
|
||||
| --- | --- | --- |
|
||||
| `--tap` | Tap / Star / Touch / EX | 500 |
|
||||
| `--hold` | Hold 系 | 1000 |
|
||||
| `--slide` | Slide 系 | 1500 |
|
||||
| `--break` | 绝赞(Break 系) | 2500 |
|
||||
|
||||
绝赞的 Great / Perfect 本身还有档位,需要时可以再细分(不给就按 Great 失分 20%、Perfect 奖励 75 算):
|
||||
|
||||
| 参数 | 含义 |
|
||||
| --- | --- |
|
||||
| `--break-great40 N` | 绝赞里 N 个 Great 是「失分 40%」档(Great2nd) |
|
||||
| `--break-great50 N` | 绝赞里 N 个 Great 是「失分 50%」档(Great3rd) |
|
||||
| `--break-perfect50 N` | 绝赞里 N 个 Perfect 是「奖励分 50」档(Perfect2nd) |
|
||||
|
||||
输出就是一个**确切**的达成率,并附上失分 / 绝赞奖励分的明细:
|
||||
|
||||
```
|
||||
判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)
|
||||
Tap/Star/Touch/EX (500) 263 个:CP 260 · Great 3
|
||||
Hold (1000) 14 个:CP 14
|
||||
Slide (1500) 19 个:CP 19
|
||||
Break (2500) 6 个:CP 6
|
||||
结果
|
||||
达成率 100.8413% (确切值)
|
||||
DX 分 900 / 906 (99.34%)
|
||||
```
|
||||
|
||||
### ② 汇总:只给总判定数(得到估计值 + 区间)
|
||||
|
||||
```powershell
|
||||
python chart_achievement.py --chart "<...>\000008_03.ma2" --cp 290 --perfect 8 --great 3 --good 1
|
||||
```
|
||||
|
||||
`--cp` / `--perfect` / `--great` / `--good` 都给总数,`--miss` 留空时自动补足
|
||||
(`miss = 总音符数 - CP - Perfect - Great - Good`)。
|
||||
|
||||
这时达成率取决于这些 Great/Good/Miss **落在哪类音符上**(落在绝赞上扣得多、落在 Tap 上扣得少),
|
||||
所以给出的是「按类别比例分摊的估计值」+ 一个区间(下界 = 扣分都落在高权重音符上,
|
||||
上界 = 都落在低权重音符上)。想要一个确切数值就用上面的 ①。
|
||||
|
||||
DX 分在两种方式下都是确定的:`3×CP + 2×Perfect + 1×Great`。
|
||||
|
||||
## 反向:已知达成率 → 判定组合 + DX 分
|
||||
|
||||
```powershell
|
||||
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 3 --achievement 100.75 # 3 = MASTER
|
||||
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 2 --achievement 99 --mode range # 2 = EXPERT
|
||||
```
|
||||
|
||||
两种模式(`--mode`):
|
||||
|
||||
| 模式 | 含义 | 什么时候用 |
|
||||
| --- | --- | --- |
|
||||
| `exact`(默认) | 只列**真的能打出**该达成率的 (Miss, Good, Great) 组合,每组都做过严格验证 | 数据可信、想知道精确答案 |
|
||||
| `range` | 列达成率**区间覆盖**该值的组合(更快、更宽松) | 判定数只是估算 / 想快速看个大概 |
|
||||
|
||||
`--limit N` 限制最多输出多少组(默认 200)。`--json` 输出 JSON。
|
||||
|
||||
输出里的 `CP+P` 是「Critical Perfect + Perfect 的总数」;`CP 范围` 是其中
|
||||
CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不能少于
|
||||
`CP+P - 绝赞音符数`)。CP 越多 DX 分越高,所以 DX 分也是这个范围。
|
||||
|
||||
### 精确模式的限制
|
||||
|
||||
精确验证用一个「坏音符背包 DP」,代价随坏音符个数增长,所以:
|
||||
|
||||
- 只严格验证 `Miss+Good+Great ≤ 12` 的组合;
|
||||
- 坏音符更多的组合不验证(输出里会提示「坏音符超过 12 个的候选组合没有验证」);
|
||||
- 若候选组合全都超过 12 个坏音符(例如 1000+ 绝赞的异常谱面),会自动退回区间模式并说明原因;
|
||||
- DP 有 8 秒时间预算,超时也退回区间模式。
|
||||
|
||||
## 计分模型(与游戏一致)
|
||||
|
||||
达成率按**音符权重**加权,不是按个数平均:
|
||||
|
||||
```
|
||||
达成率 = 100 × Σ(单音符得分) / 理论分W + Σ(绝赞奖励分) / 绝赞理论奖励分B
|
||||
```
|
||||
|
||||
- 理论分 W = Σ 每个计分音符的权重(Tap/Star/Touch 500、Hold 系 1000、Slide 系 1500、Break 系 2500)
|
||||
- 绝赞理论奖励分 B = 100 × 绝赞音符个数(Break / Break(长按判定) / BreakSlide / EXBreak)
|
||||
- 满分 = **101.0000%**(谱面有绝赞)/ **100.0000%**(无绝赞)
|
||||
- 接续滑条节点(`CNSI_` 等 ConnectSlide)**不计分、不计入总数**
|
||||
- DX 分 = `3×CP + 2×Perfect + 1×Great`(与音符类型无关)
|
||||
|
||||
### 各类音符的失分 / 奖励分
|
||||
|
||||
| 音符组(权重) | CP | Perfect | Great | Good | Miss |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Tap / Star / Touch(500) | 0 | 0 | 100 | 250 | 500 |
|
||||
| Hold 系(1000) | 0 | 0 | 200 | 500 | 1000 |
|
||||
| Slide 系(1500) | 0 | 0 | 300 | 750 | 1500 |
|
||||
| Break(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
|
||||
| Break 长按判定(2500) | 0 | 0 | 500 | 1500 | 2500 |
|
||||
| BreakSlide(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
|
||||
| EX(500) | 0 | — | — | — | 500 |
|
||||
| EXBreak(2500) | 0 | — | — | — | 2500 |
|
||||
|
||||
(表里是「失分」,即 `权重 - 得分`;CP/Perfect 的失分都是 0,所以
|
||||
**除绝赞外,Critical Perfect 与 Perfect 加的达成率完全一样**。)
|
||||
|
||||
绝赞音符额外有奖励分:
|
||||
|
||||
| 判定 | 奖励分 |
|
||||
| --- | --- |
|
||||
| Critical Perfect | 100 |
|
||||
| Perfect | 75(FastPerfect/LatePerfect)/ 50(Perfect2nd) |
|
||||
| Great | 40 |
|
||||
| Good | 30 |
|
||||
| Miss | 0 |
|
||||
|
||||
这就是绝赞必须区分 CP / Perfect 的原因:普通分一样,但奖励分不同
|
||||
(CP 100、Perfect 75 或 50)。
|
||||
|
||||
### 音符组的划分依据(反编译源码)
|
||||
|
||||
- 走长按结算的 Break(`BRHLD`)判定档位更少(Great 只有一档、Perfect 只有 75 档),所以单独成组。
|
||||
- 滑条系(`BRSI_`/`BRSCL`…)走 SlideOut 判定表,只有 Great 三档、没有 Perfect 档。
|
||||
- EX 音符(`XTP`/`XST`/`BXTAP`/`BXSTR`,即 ExTap/ExStar/ExBreakTap/ExBreakStar)
|
||||
走 ExTap 判定表,非 Critical 的窗口宽度为 0,所以**只能判 Critical 或 Miss**。
|
||||
- `XHO`/`BXHLD`(ExHold/ExBreakHold)头部用 ExTap 表但结果还要过长按结算,
|
||||
等价于普通 Hold / Break(长按判定)。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `ma2.py` | `.ma2` 解析、指令→音符组映射、`Music.xml` 读取、路径拼接 |
|
||||
| `score.py` | 计分模型、正向求解、反向求解(区间 / 精确) |
|
||||
| `chart_achievement.py` | 命令行界面 / 交互向导 |
|
||||
|
||||
## 已知局限
|
||||
|
||||
- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%)
|
||||
和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` /
|
||||
`--break-perfect50` 指定,不给就按 20% / 75 算。
|
||||
- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数,
|
||||
落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。
|
||||
- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。
|
||||
- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告
|
||||
(全部 5419 个游戏谱面实测一致)。
|
||||
@@ -0,0 +1,651 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""舞萌DX 谱面达成率 / DX 分计算器。
|
||||
|
||||
正向:给 谱面 + 各判定数量 -> 算达成率与 DX 分
|
||||
反向:给 谱面 + 目标达成率 -> 倒算所有可行的判定组合与 DX 分
|
||||
|
||||
只依赖 Python 标准库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from fractions import Fraction
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart,
|
||||
chart_path, find_music_dir, level_display, parse_difficulty,
|
||||
parse_ma2, parse_music_xml)
|
||||
from score import (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL,
|
||||
JUDGMENTS, Theory, class_capacity, dx_score,
|
||||
reference_achievement, solve_exact, solve_forward,
|
||||
solve_reverse, theory_from_chart)
|
||||
|
||||
try: # 中文输出到管道时避免 UnicodeEncodeError
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
sys.stderr.reconfigure(errors="replace")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
LINE = "-" * 60
|
||||
|
||||
|
||||
def pct(value) -> str:
|
||||
return "%.4f%%" % float(value)
|
||||
|
||||
|
||||
def die(msg: str, code: int = 2) -> "None":
|
||||
print("[错误] " + msg, file=sys.stderr)
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- 载入谱面
|
||||
def _enrich(chart: Chart, path: str) -> Chart:
|
||||
"""从同目录的 Music.xml 补全曲名 / 难度 / 等级(拿不到就跳过)。"""
|
||||
base = os.path.basename(path)
|
||||
stem = os.path.splitext(base)[0]
|
||||
if "_" in stem:
|
||||
left, right = stem.split("_", 1)
|
||||
try:
|
||||
chart.music_id = int(left)
|
||||
chart.difficulty_index = int(right)
|
||||
chart.difficulty_name = DIFFICULTY_NAMES[chart.difficulty_index]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
xml_path = os.path.join(os.path.dirname(path), "Music.xml")
|
||||
if os.path.isfile(xml_path):
|
||||
info = parse_music_xml(xml_path)
|
||||
chart.song_name = info.get("name") or chart.song_name
|
||||
notes = info.get("notes") or []
|
||||
idx = chart.difficulty_index
|
||||
if idx is not None and 0 <= idx < len(notes):
|
||||
entry = notes[idx]
|
||||
chart.level_text = level_display(entry.get("level"), entry.get("levelDecimal"))
|
||||
try:
|
||||
chart.max_notes_xml = int(entry.get("maxNotes") or 0)
|
||||
except (TypeError, ValueError):
|
||||
chart.max_notes_xml = None
|
||||
return chart
|
||||
|
||||
|
||||
def load_from_file(path: str) -> Chart:
|
||||
path = os.path.abspath(os.path.expanduser(path))
|
||||
if not os.path.isfile(path):
|
||||
die("谱面文件不存在:%s" % path)
|
||||
return _enrich(parse_ma2(path), path)
|
||||
|
||||
|
||||
def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
|
||||
music_dir = find_music_dir(game_path)
|
||||
path = chart_path(music_dir, music_id, difficulty)
|
||||
if not os.path.isfile(path):
|
||||
alt = os.path.join(music_dir, "music%06d" % music_id)
|
||||
hint = ""
|
||||
if os.path.isdir(alt):
|
||||
have = sorted(f for f in os.listdir(alt) if f.lower().endswith(".ma2"))
|
||||
hint = ";该曲目现有谱面:" + (", ".join(have) if have else "无")
|
||||
die("找不到谱面:%s%s" % (path, hint))
|
||||
chart = parse_ma2(path)
|
||||
chart.music_id = music_id
|
||||
chart.difficulty_index = difficulty
|
||||
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||||
chart = _enrich(chart, path)
|
||||
chart.difficulty_index = difficulty
|
||||
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||||
return chart
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 展示
|
||||
def print_chart(chart: Chart, th: Theory) -> None:
|
||||
print(LINE)
|
||||
print("谱面")
|
||||
print(" 文件 %s" % chart.path)
|
||||
if chart.song_name:
|
||||
print(" 曲目 %s" % chart.song_name)
|
||||
if chart.music_id is not None:
|
||||
print(" 编号 %d" % chart.music_id)
|
||||
if chart.difficulty_name:
|
||||
print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
|
||||
chart.difficulty_index if chart.difficulty_index is not None else 0))
|
||||
if chart.level_text:
|
||||
print(" 等级 %s" % chart.level_text)
|
||||
parts = []
|
||||
for g in GROUP_ORDER:
|
||||
n = th.counts.get(g, 0)
|
||||
if n:
|
||||
parts.append("%s %d" % (GROUP_LABEL[g], n))
|
||||
print(" 音符 %s" % " · ".join(parts))
|
||||
print(" 合计 %d 个计分音符" % th.total_notes)
|
||||
if chart.connect_count:
|
||||
print(" (另有 %d 个接续滑条节点,不计分)" % chart.connect_count)
|
||||
bonus = " + 绝赞奖励 %d" % th.break_bonus_score if th.break_bonus_score else ""
|
||||
print(" 理论分 %d%s" % (th.all_perfect_score, bonus))
|
||||
print(" 理论DX %d" % th.max_dx)
|
||||
print(" 满分 %s" % pct(th.max_achievement))
|
||||
for w in chart.warnings:
|
||||
print(" [警告] %s" % w)
|
||||
declared = chart.totals.get("TTM_SCR_SS")
|
||||
if declared is not None and declared != th.all_perfect_score:
|
||||
print(" [警告] 谱面自带 TTM_SCR_SS=%d 与计算出的理论分 %d 不一致"
|
||||
% (declared, th.all_perfect_score))
|
||||
|
||||
|
||||
def print_forward(chart: Chart, result) -> None:
|
||||
th = result.theory
|
||||
print(LINE)
|
||||
print("判定输入(汇总)")
|
||||
for j in JUDGMENTS:
|
||||
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
|
||||
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
|
||||
print(LINE)
|
||||
print("结果")
|
||||
exact = result.achievement_min == result.achievement_max
|
||||
if exact:
|
||||
print(" 达成率 %s (确切值)" % pct(result.achievement_min))
|
||||
else:
|
||||
print(" 达成率 %s (按音符类别比例分摊的估计值)" % pct(result.achievement_ref))
|
||||
print(" 区间 %s ~ %s"
|
||||
% (pct(result.achievement_min), pct(result.achievement_max)))
|
||||
print(" DX 分 %d / %d (%.2f%%)"
|
||||
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
|
||||
if not exact:
|
||||
print()
|
||||
print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——")
|
||||
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。")
|
||||
print(" 想要一个确切数值:用 --tap / --hold / --slide / --break 按类别给判定数。")
|
||||
print(LINE)
|
||||
|
||||
|
||||
_EXACT_KEYS = ("cp", "perfect", "perfect50", "great", "great40", "great50", "good", "miss")
|
||||
_EXACT_KEY_LABEL = {
|
||||
"cp": "CP", "perfect": "Perfect", "perfect50": "Perfect(50)",
|
||||
"great": "Great", "great40": "Great(40%)", "great50": "Great(50%)",
|
||||
"good": "Good", "miss": "Miss",
|
||||
}
|
||||
# 打印时用纯 ASCII,避免中文宽字符把表格撑歪
|
||||
_EXACT_CLASS_TITLE = {
|
||||
"tap": "Tap/Star/Touch/EX", "hold": "Hold", "slide": "Slide", "break": "Break",
|
||||
}
|
||||
|
||||
|
||||
def print_exact(chart: Chart, result) -> None:
|
||||
th = result.theory
|
||||
print(LINE)
|
||||
print("判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)")
|
||||
for cls in CLASS_ORDER:
|
||||
cap = class_capacity(th, cls)
|
||||
if not cap:
|
||||
continue
|
||||
parts = []
|
||||
for key in _EXACT_KEYS:
|
||||
n = result.spec.get(cls, {}).get(key, 0)
|
||||
if n:
|
||||
parts.append("%s %d" % (_EXACT_KEY_LABEL[key], n))
|
||||
print(" %-24s %4d 个:%s"
|
||||
% ("%s (%d)" % (_EXACT_CLASS_TITLE[cls], CLASS_WEIGHT[cls]), cap,
|
||||
" · ".join(parts) if parts else "-"))
|
||||
print(LINE)
|
||||
print("结果")
|
||||
print(" 达成率 %s (确切值)" % pct(result.achievement))
|
||||
print(" DX 分 %d / %d (%.2f%%)"
|
||||
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
|
||||
if th.all_perfect_score:
|
||||
print(" 失分 %d / %d (%.4f%%)"
|
||||
% (result.loss_total, th.all_perfect_score,
|
||||
100.0 * result.loss_total / th.all_perfect_score))
|
||||
if th.break_bonus_score:
|
||||
print(" 绝赞奖励 %d / %d (%.4f%%)"
|
||||
% (result.bonus_total, th.break_bonus_score,
|
||||
100.0 * result.bonus_total / th.break_bonus_score))
|
||||
print(LINE)
|
||||
|
||||
|
||||
def print_reverse(chart: Chart, outcome) -> None:
|
||||
th = outcome["theory"]
|
||||
rows = outcome["rows"]
|
||||
mode = outcome.get("mode", "exact")
|
||||
print(LINE)
|
||||
print("目标达成率 %s [%s]" % (pct(outcome["target"]),
|
||||
"精确模式" if mode == "exact" else "区间模式"))
|
||||
note = outcome.get("note")
|
||||
if note:
|
||||
print(" [说明] %s" % note)
|
||||
if not rows:
|
||||
if mode == "exact":
|
||||
cap = outcome.get("exact_cap") or 0
|
||||
print(" (在严格验证范围内没有找到解:坏音符 ≤ %d 的组合里没有能精确打出该值的)" % cap)
|
||||
if outcome.get("more_unverified"):
|
||||
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||||
else:
|
||||
print(" (无解:该达成率在这个谱面上打不出来)")
|
||||
print(LINE)
|
||||
return
|
||||
if mode == "exact":
|
||||
print(" Miss Good Great CP+P CP 范围 DX 分范围")
|
||||
else:
|
||||
print(" Miss Good Great CP+P CP 范围 达成率范围 DX 分范围")
|
||||
for r in rows:
|
||||
cp_range = "%d~%d" % (r.cp_min, r.cp_max) if r.cp_min != r.cp_max else str(r.cp_min)
|
||||
dx_range = "%d~%d" % (r.dx_min, r.dx_max) if r.dx_min != r.dx_max else str(r.dx_min)
|
||||
if mode == "exact":
|
||||
print(" %4d %4d %5d %4d %-11s %s"
|
||||
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, dx_range))
|
||||
else:
|
||||
ach = "%s ~ %s" % (pct(r.achievement_min), pct(r.achievement_max))
|
||||
print(" %4d %4d %5d %4d %-11s %-24s %s"
|
||||
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, ach, dx_range))
|
||||
print()
|
||||
if mode == "exact":
|
||||
print(" 共 %d 组能精确打出 %s 的判定组合(CP+P = Critical Perfect 与 Perfect 之和)"
|
||||
% (len(rows), pct(outcome["target"])))
|
||||
cap = outcome.get("exact_cap") or 0
|
||||
print(" 已逐个严格验证 Miss+Good+Great ≤ %d 的组合;每个组合都真的能打出该达成率" % cap)
|
||||
if outcome.get("unverified"):
|
||||
print(" [注意] 另有 %d 组候选(坏音符超过 %d 个)未做精确验证,未列入结果"
|
||||
% (outcome["unverified"], cap))
|
||||
if outcome.get("more_unverified"):
|
||||
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||||
else:
|
||||
print(" 共 %d 组可行组合(CP+P = Critical Perfect 与 Perfect 之和)" % len(rows))
|
||||
print(" 区间模式:只保证达成率落在这个范围内,不保证能恰好打出目标值")
|
||||
if outcome["truncated"]:
|
||||
print(" [注意] 结果被 --limit / 扫描上限截断,可能还有更多组合")
|
||||
print(" DX 分 = 3*CP + 2*Perfect + 1*Great;CP 越靠范围上端,DX 分越高")
|
||||
print(LINE)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ JSON
|
||||
def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
|
||||
data = {
|
||||
"chart": {
|
||||
"path": chart.path,
|
||||
"music_id": chart.music_id,
|
||||
"song_name": chart.song_name,
|
||||
"difficulty": chart.difficulty_name,
|
||||
"difficulty_index": chart.difficulty_index,
|
||||
"level_text": chart.level_text,
|
||||
"notes": th.counts,
|
||||
"total_notes": th.total_notes,
|
||||
"connect_slide": chart.connect_count,
|
||||
"all_perfect_score": th.all_perfect_score,
|
||||
"break_bonus_score": th.break_bonus_score,
|
||||
"max_dx": th.max_dx,
|
||||
"max_achievement": float(th.max_achievement),
|
||||
"warnings": chart.warnings,
|
||||
},
|
||||
}
|
||||
data.update(payload)
|
||||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 交互
|
||||
def ask(prompt: str, default: str = "") -> str:
|
||||
try:
|
||||
text = input(prompt).strip()
|
||||
except EOFError:
|
||||
text = ""
|
||||
return text or default
|
||||
|
||||
|
||||
def ask_int(prompt: str, default: Optional[int] = None) -> Optional[int]:
|
||||
while True:
|
||||
raw = ask(prompt, "" if default is None else str(default))
|
||||
if raw == "" and default is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
print(" 请输入整数")
|
||||
|
||||
|
||||
def interactive(args) -> None:
|
||||
print("=" * 60)
|
||||
print("舞萌DX 谱面达成率 / DX 分 计算器")
|
||||
print("=" * 60)
|
||||
chart = None
|
||||
while chart is None:
|
||||
mode = ask("谱面来源:1) .ma2 文件路径 2) 游戏路径 + musicid [1]: ", "1")
|
||||
if mode.startswith("2"):
|
||||
game = ask("游戏路径(Sinmai_Data 目录): ")
|
||||
if not game:
|
||||
continue
|
||||
mid = ask_int("musicid: ")
|
||||
if mid is None:
|
||||
continue
|
||||
diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
|
||||
try:
|
||||
chart = load_from_game(game, mid, parse_difficulty(diff))
|
||||
except SystemExit:
|
||||
chart = None
|
||||
else:
|
||||
path = ask("谱面文件路径: ")
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
chart = load_from_file(path)
|
||||
except SystemExit:
|
||||
chart = None
|
||||
th = theory_from_chart(chart)
|
||||
print_chart(chart, th)
|
||||
|
||||
mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)"
|
||||
" 3) 由达成率倒算判定数 [1]: ", "1")
|
||||
if mode.startswith("3"):
|
||||
while True:
|
||||
raw = ask("目标达成率(如 99.5 或 100.5): ")
|
||||
if not raw:
|
||||
return
|
||||
try:
|
||||
target = Fraction(raw.rstrip("%"))
|
||||
break
|
||||
except (ValueError, ZeroDivisionError):
|
||||
print(" 请输入数字")
|
||||
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
|
||||
args.mode = "range" if mode.startswith("2") else "exact"
|
||||
run_reverse(chart, th, target, args)
|
||||
elif mode.startswith("2"):
|
||||
spec = {}
|
||||
for cls in CLASS_ORDER:
|
||||
cap = class_capacity(th, cls)
|
||||
if not cap:
|
||||
continue
|
||||
raw = ask(" %s(%d 个)CP/Perfect/Great/Good/Miss [%d/0/0/0/0]: "
|
||||
% (CLASS_LABEL[cls], cap, cap))
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
spec[cls] = parse_class_counts(raw, cls)
|
||||
except ValueError as exc:
|
||||
print(" [错误] %s" % exc)
|
||||
run_exact(chart, th, spec, args)
|
||||
else:
|
||||
values = {}
|
||||
for j in JUDGMENTS:
|
||||
if j == "miss":
|
||||
continue
|
||||
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
|
||||
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
|
||||
run_forward(chart, th, values, args)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 执行
|
||||
def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int]:
|
||||
given = {j: int(raw.get(j) or 0) for j in JUDGMENTS}
|
||||
if raw.get("miss") is None:
|
||||
given["miss"] = th.total_notes - sum(given[j] for j in JUDGMENTS if j != "miss")
|
||||
if given["miss"] < 0:
|
||||
die("CP+Perfect+Great+Good 合计 %d 超过了音符总数 %d"
|
||||
% (sum(given[j] for j in JUDGMENTS if j != "miss"), th.total_notes))
|
||||
total = sum(given.values())
|
||||
if total != th.total_notes:
|
||||
die("判定数之和 %d 与音符总数 %d 不一致(Miss 请留空自动补足)" % (total, th.total_notes))
|
||||
return given
|
||||
|
||||
|
||||
_CLASS_KEYS = ("cp", "perfect", "great", "good", "miss")
|
||||
|
||||
|
||||
def parse_class_counts(text: str, cls: str) -> Dict[str, int]:
|
||||
"""把 "CP/Perfect/Great/Good/Miss"(斜杠、逗号或空格分隔)解析成判定数字典。"""
|
||||
parts = [p for p in re.split(r"[\/,\s]+", str(text).strip()) if p]
|
||||
if len(parts) > len(_CLASS_KEYS):
|
||||
raise ValueError("%s 最多 %d 个数(CP/Perfect/Great/Good/Miss):%s"
|
||||
% (CLASS_LABEL[cls], len(_CLASS_KEYS), text))
|
||||
out: Dict[str, int] = {}
|
||||
for key, raw in zip(_CLASS_KEYS, parts):
|
||||
try:
|
||||
n = int(raw)
|
||||
except ValueError:
|
||||
raise ValueError("%s 里的「%s」不是整数:%s" % (CLASS_LABEL[cls], raw, text))
|
||||
if n < 0:
|
||||
raise ValueError("%s 里不能有负数:%s" % (CLASS_LABEL[cls], text))
|
||||
if n:
|
||||
out[key] = n
|
||||
return out
|
||||
|
||||
|
||||
def build_spec(args) -> Dict[str, Dict[str, int]]:
|
||||
"""把 --tap/--hold/--slide/--break 以及绝赞档位参数拼成 solve_exact 的输入。"""
|
||||
spec: Dict[str, Dict[str, int]] = {}
|
||||
for cls in CLASS_ORDER:
|
||||
text = getattr(args, cls, None)
|
||||
if text:
|
||||
spec[cls] = parse_class_counts(text, cls)
|
||||
brk = dict(spec.get("break") or {})
|
||||
perfect_total = brk.get("perfect", 0)
|
||||
great_total = brk.get("great", 0)
|
||||
p50 = int(args.break_perfect50 or 0)
|
||||
g40 = int(args.break_great40 or 0)
|
||||
g50 = int(args.break_great50 or 0)
|
||||
if p50 or g40 or g50:
|
||||
if p50 > perfect_total:
|
||||
raise ValueError("--break-perfect50 %d 超过绝赞 Perfect 总数 %d"
|
||||
"(先在 --break 里写 CP/Perfect/Great/Good/Miss)" % (p50, perfect_total))
|
||||
if g40 + g50 > great_total:
|
||||
raise ValueError("--break-great40 + --break-great50 = %d 超过绝赞 Great 总数 %d"
|
||||
% (g40 + g50, great_total))
|
||||
if p50:
|
||||
brk["perfect"] = perfect_total - p50
|
||||
brk["perfect50"] = p50
|
||||
if g40:
|
||||
brk["great40"] = g40
|
||||
if g50:
|
||||
brk["great50"] = g50
|
||||
if great_total - g40 - g50:
|
||||
brk["great"] = great_total - g40 - g50
|
||||
else:
|
||||
brk.pop("great", None)
|
||||
brk = {k: v for k, v in brk.items() if v}
|
||||
spec["break"] = brk
|
||||
return spec
|
||||
|
||||
|
||||
def run_exact(chart: Chart, th: Theory, spec: Dict[str, Dict[str, int]], args) -> None:
|
||||
try:
|
||||
result = solve_exact(th, spec)
|
||||
except ValueError as exc:
|
||||
die(str(exc))
|
||||
if args.json:
|
||||
print(to_json(chart, th, {
|
||||
"mode": "forward_exact",
|
||||
"judges_by_class": result.spec,
|
||||
"achievement": float(result.achievement),
|
||||
"dx_score": result.dx,
|
||||
"loss_total": result.loss_total,
|
||||
"bonus_total": result.bonus_total,
|
||||
}))
|
||||
return
|
||||
print_chart(chart, th)
|
||||
print_exact(chart, result)
|
||||
|
||||
|
||||
def run_forward(chart: Chart, th: Theory,
|
||||
raw: Dict[str, Optional[int]], args) -> None:
|
||||
judges = normalize_judges(th, raw)
|
||||
result = solve_forward(th, judges)
|
||||
if args.json:
|
||||
print(to_json(chart, th, {
|
||||
"mode": "forward",
|
||||
"judges": judges,
|
||||
"achievement_min": float(result.achievement_min),
|
||||
"achievement_max": float(result.achievement_max),
|
||||
"achievement_reference": float(result.achievement_ref),
|
||||
"dx_score": result.dx,
|
||||
}))
|
||||
return
|
||||
print_chart(chart, th)
|
||||
print_forward(chart, result)
|
||||
|
||||
|
||||
def run_reverse(chart: Chart, th: Theory,
|
||||
target: Fraction, args) -> None:
|
||||
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
|
||||
if args.json:
|
||||
print(to_json(chart, th, {
|
||||
"mode": "reverse",
|
||||
"search_mode": outcome["mode"],
|
||||
"target": float(target),
|
||||
"truncated": outcome["truncated"],
|
||||
"exact_cap": outcome.get("exact_cap"),
|
||||
"unverified": outcome.get("unverified", 0),
|
||||
"more_unverified": outcome.get("more_unverified", False),
|
||||
"note": outcome.get("note"),
|
||||
"solutions": [{
|
||||
"miss": r.miss, "good": r.good, "great": r.great,
|
||||
"critical_perfect_plus_perfect": r.cp_plus_perfect,
|
||||
"critical_perfect_min": r.cp_min, "critical_perfect_max": r.cp_max,
|
||||
"achievement_min": float(r.achievement_min),
|
||||
"achievement_max": float(r.achievement_max),
|
||||
"dx_min": r.dx_min, "dx_max": r.dx_max,
|
||||
} for r in outcome["rows"]],
|
||||
}))
|
||||
return
|
||||
print_chart(chart, th)
|
||||
print_reverse(chart, outcome)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="chart_achievement.py",
|
||||
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""示例:
|
||||
# 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间)
|
||||
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
|
||||
|
||||
# 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的)
|
||||
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --tap 260/0/3 --break 6
|
||||
|
||||
# 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2
|
||||
|
||||
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5
|
||||
|
||||
# 判定数只是估算时,用区间模式
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5 --mode range
|
||||
""")
|
||||
src = p.add_argument_group("谱面来源(二选一)")
|
||||
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
|
||||
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)")
|
||||
src.add_argument("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
|
||||
src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
|
||||
help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
|
||||
"3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
|
||||
|
||||
fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间")
|
||||
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
|
||||
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
|
||||
fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
|
||||
fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
|
||||
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)")
|
||||
|
||||
ex = p.add_argument_group(
|
||||
"正向计算(精确):按音符类别给判定数,得到确切的达成率",
|
||||
"每个类别写 CP/Perfect/Great/Good/Miss 五个数(斜杠或空格分隔,后面可以省略),"
|
||||
"没给满的部分按 CP 算。")
|
||||
ex.add_argument("--tap", metavar="CP/P/G/GD/M",
|
||||
help="Tap/Star/Touch/EX 类(权重 500)")
|
||||
ex.add_argument("--hold", metavar="CP/P/G/GD/M", help="Hold 系(权重 1000)")
|
||||
ex.add_argument("--slide", metavar="CP/P/G/GD/M", help="Slide 系(权重 1500)")
|
||||
ex.add_argument("--break", dest="break", metavar="CP/P/G/GD/M",
|
||||
help="绝赞(权重 2500;Great/Perfect 档位见下面三个参数)")
|
||||
ex.add_argument("--break-perfect50", type=int, metavar="N",
|
||||
help="绝赞 Perfect 里奖励分只有 50 的个数(Perfect2nd)")
|
||||
ex.add_argument("--break-great40", type=int, metavar="N",
|
||||
help="绝赞 Great 里失分 40%% 的个数(Great2nd)")
|
||||
ex.add_argument("--break-great50", type=int, metavar="N",
|
||||
help="绝赞 Great 里失分 50%% 的个数(Great3rd)")
|
||||
|
||||
rv = p.add_argument_group("反向计算:已知达成率")
|
||||
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
|
||||
rv.add_argument("--mode", choices=("exact", "range"), default="exact",
|
||||
help="exact(默认):只列真的能打出该达成率的组合;"
|
||||
"range:列出达成率区间覆盖该值的组合(更快,输入不精确时用)")
|
||||
rv.add_argument("--limit", type=int, default=200, metavar="N",
|
||||
help="反向计算最多输出多少组解(默认 200)")
|
||||
|
||||
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
|
||||
if args.chart and args.game:
|
||||
die("--chart 与 --game 只能给一个")
|
||||
if not args.chart and not args.game:
|
||||
interactive(args)
|
||||
return 0
|
||||
if args.chart:
|
||||
chart = load_from_file(args.chart)
|
||||
if args.level is not None:
|
||||
try:
|
||||
want = parse_difficulty(args.level)
|
||||
except ValueError as exc:
|
||||
die(str(exc))
|
||||
if chart.difficulty_index is not None and want != chart.difficulty_index:
|
||||
print("[注意] --level 给的是 %s,但谱面文件名里是 %s,按文件名算"
|
||||
% (DIFFICULTY_NAMES[want], DIFFICULTY_NAMES[chart.difficulty_index]),
|
||||
file=sys.stderr)
|
||||
else:
|
||||
if args.music_id is None:
|
||||
die("使用 --game 时必须同时给 --music-id")
|
||||
if args.level is None:
|
||||
print("[提示] 没有给 --level,按默认难度 3(MASTER)计算"
|
||||
"(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)", file=sys.stderr)
|
||||
try:
|
||||
difficulty = parse_difficulty(args.level if args.level is not None else "master")
|
||||
except ValueError as exc:
|
||||
die(str(exc))
|
||||
chart = load_from_game(args.game, args.music_id, difficulty)
|
||||
|
||||
th = theory_from_chart(chart)
|
||||
if th.total_notes == 0:
|
||||
die("谱面里没有解析到任何计分音符:%s" % chart.path)
|
||||
|
||||
if args.achievement:
|
||||
try:
|
||||
target = Fraction(str(args.achievement).rstrip("%"))
|
||||
except (ValueError, ZeroDivisionError):
|
||||
die("--achievement 需要是数字,如 99.5")
|
||||
if target > th.max_achievement:
|
||||
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
|
||||
if target < 0:
|
||||
die("目标达成率不能为负")
|
||||
run_reverse(chart, th, target, args)
|
||||
return 0
|
||||
|
||||
class_flags = {"tap": args.tap, "hold": args.hold, "slide": args.slide,
|
||||
"break": getattr(args, "break", None),
|
||||
"perfect50": args.break_perfect50,
|
||||
"great40": args.break_great40, "great50": args.break_great50}
|
||||
if any(v for v in class_flags.values()):
|
||||
if any(v is not None for v in (args.cp, args.perfect, args.great, args.good, args.miss)):
|
||||
die("汇总判定数(--cp/--perfect/--great/--good/--miss)"
|
||||
"和按类别判定数(--tap/--hold/--slide/--break)只能给一种")
|
||||
try:
|
||||
spec = build_spec(args)
|
||||
except ValueError as exc:
|
||||
die(str(exc))
|
||||
run_exact(chart, th, spec, args)
|
||||
return 0
|
||||
|
||||
raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great,
|
||||
"good": args.good, "miss": args.miss}
|
||||
if all(v is None for v in raw.values()):
|
||||
die("请给判定数:汇总用 --cp/--perfect/--great/--good,"
|
||||
"精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算")
|
||||
run_forward(chart, th, raw, args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,367 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""舞萌DX (maimai DX) .ma2 谱面解析。
|
||||
|
||||
指令 -> 音符类型 的对应表来自反编译源码
|
||||
source/Assembly-CSharp/Manager/Ma2fileRecordID.cs
|
||||
(s_Ma2fileRecord_Data 里 Ma2Category.MA2_Note 的 102 条记录)
|
||||
计分类别的划分复刻
|
||||
source/Assembly-CSharp/Manager/GamePlayManager.cs NoteType2ScoreType()
|
||||
理论分权重来自
|
||||
source/Assembly-CSharp/Manager/NotesReader.cs calcTotal()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# ---------------------------------------------------------------- 计分权重
|
||||
WEIGHT_TAP = 500 # TAP / XTP / STR / XST / TTP(Touch 与 Tap 同为 500)
|
||||
WEIGHT_HOLD = 1000 # HLD / XHO / THO
|
||||
WEIGHT_SLIDE = 1500 # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_
|
||||
WEIGHT_BREAK = 2500 # BRK / BST / BRTAP / BRHLD / BRSTR / BRSI_ ... 以及 ExBreak 系
|
||||
BREAK_BONUS_THEORY = 100 # 每个绝赞(Break)的理论奖励分
|
||||
|
||||
CATEGORY_TAP = "Tap"
|
||||
CATEGORY_HOLD = "Hold"
|
||||
CATEGORY_SLIDE = "Slide"
|
||||
CATEGORY_BREAK = "Break"
|
||||
CATEGORY_CONNECT = "ConnectSlide"
|
||||
|
||||
CATEGORY_ORDER = (CATEGORY_TAP, CATEGORY_HOLD, CATEGORY_SLIDE, CATEGORY_BREAK)
|
||||
CATEGORY_WEIGHT = {
|
||||
CATEGORY_TAP: WEIGHT_TAP,
|
||||
CATEGORY_HOLD: WEIGHT_HOLD,
|
||||
CATEGORY_SLIDE: WEIGHT_SLIDE,
|
||||
CATEGORY_BREAK: WEIGHT_BREAK,
|
||||
}
|
||||
CATEGORY_LABEL = {
|
||||
CATEGORY_TAP: "Tap/Touch",
|
||||
CATEGORY_HOLD: "Hold",
|
||||
CATEGORY_SLIDE: "Slide",
|
||||
CATEGORY_BREAK: "Break",
|
||||
}
|
||||
|
||||
# ------------------------------------------------- ma2 指令 -> NotesTypeID.Def
|
||||
NOTE_COMMAND_TYPES: Dict[str, str] = {
|
||||
# --- 现行指令 ---
|
||||
"TAP": "Begin", "BRK": "Break", "XTP": "ExTap", "HLD": "Hold", "XHO": "ExHold",
|
||||
"STR": "Star", "BST": "BreakStar", "XST": "ExStar",
|
||||
"TTP": "TouchTap", "THO": "TouchHold",
|
||||
"SI_": "Slide", "SCL": "Slide", "SCR": "Slide", "SUL": "Slide", "SUR": "Slide",
|
||||
"SSL": "Slide", "SSR": "Slide", "SV_": "Slide", "SXL": "Slide", "SXR": "Slide",
|
||||
"SLL": "Slide", "SLR": "Slide", "SF_": "Slide",
|
||||
# --- 旧式别名(游戏数据里真实存在)---
|
||||
"NMTAP": "Begin", "BRTAP": "Break", "EXTAP": "ExTap", "BXTAP": "ExBreakTap",
|
||||
"NMHLD": "Hold", "BRHLD": "BreakHold", "EXHLD": "ExHold", "BXHLD": "ExBreakHold",
|
||||
"NMSTR": "Star", "BRSTR": "BreakStar", "EXSTR": "ExStar", "BXSTR": "ExBreakStar",
|
||||
"NMTTP": "TouchTap", "NMTHO": "TouchHold",
|
||||
"NMSI_": "Slide", "BRSI_": "BreakSlide", "EXSI_": "ExSlide", "BXSI_": "ExBreakSlide", "CNSI_": "ConnectSlide",
|
||||
"NMSCL": "Slide", "BRSCL": "BreakSlide", "EXSCL": "ExSlide", "BXSCL": "ExBreakSlide", "CNSCL": "ConnectSlide",
|
||||
"NMSCR": "Slide", "BRSCR": "BreakSlide", "EXSCR": "ExSlide", "BXSCR": "ExBreakSlide", "CNSCR": "ConnectSlide",
|
||||
"NMSUL": "Slide", "BRSUL": "BreakSlide", "EXSUL": "ExSlide", "BXSUL": "ExBreakSlide", "CNSUL": "ConnectSlide",
|
||||
"NMSUR": "Slide", "BRSUR": "BreakSlide", "EXSUR": "ExSlide", "BXSUR": "ExBreakSlide", "CNSUR": "ConnectSlide",
|
||||
"NMSSL": "Slide", "BRSSL": "BreakSlide", "EXSSL": "ExSlide", "BXSSL": "ExBreakSlide", "CNSSL": "ConnectSlide",
|
||||
"NMSSR": "Slide", "BRSSR": "BreakSlide", "EXSSR": "ExSlide", "BXSSR": "ExBreakSlide", "CNSSR": "ConnectSlide",
|
||||
"NMSV_": "Slide", "BRSV_": "BreakSlide", "EXSV_": "ExSlide", "BXSV_": "ExBreakSlide", "CNSV_": "ConnectSlide",
|
||||
"NMSXL": "Slide", "BRSXL": "BreakSlide", "EXSXL": "ExSlide", "BXSXL": "ExBreakSlide", "CNSXL": "ConnectSlide",
|
||||
"NMSXR": "Slide", "BRSXR": "BreakSlide", "EXSXR": "ExSlide", "BXSXR": "ExBreakSlide", "CNSXR": "ConnectSlide",
|
||||
"NMSLL": "Slide", "BRSLL": "BreakSlide", "EXSLL": "ExSlide", "BXSLL": "ExBreakSlide", "CNSLL": "ConnectSlide",
|
||||
"NMSLR": "Slide", "BRSLR": "BreakSlide", "EXSLR": "ExSlide", "BXSLR": "ExBreakSlide", "CNSLR": "ConnectSlide",
|
||||
"NMSF_": "Slide", "BRSF_": "BreakSlide", "EXSF_": "ExSlide", "BXSF_": "ExBreakSlide", "CNSF_": "ConnectSlide",
|
||||
}
|
||||
|
||||
_BREAK_DEFS = frozenset((
|
||||
"Break", "BreakHold", "BreakStar", "BreakSlide",
|
||||
"ExBreakTap", "ExBreakHold", "ExBreakStar", "ExBreakSlide",
|
||||
))
|
||||
_SLIDE_DEFS = frozenset(("Slide", "BreakSlide", "ExSlide", "ExBreakSlide"))
|
||||
_HOLD_DEFS = frozenset(("Hold", "BreakHold", "ExHold", "ExBreakHold", "TouchHold"))
|
||||
_TOUCH_DEFS = frozenset(("TouchTap", "TouchHold"))
|
||||
_CONNECT_DEFS = frozenset(("ConnectSlide",))
|
||||
|
||||
# 判定时机只有 Critical / TooLate 的 EX 音符(ExTap/ExStar/ExBreakTap/ExBreakStar)
|
||||
EX_DEFS = frozenset(("ExTap", "ExStar", "ExBreakTap", "ExBreakStar"))
|
||||
|
||||
# ---------------------------------------------------------------- 音符组
|
||||
# 「音符组」= 计分行为完全相同的一类音符:权重、可选判定、失分档位、绝赞奖励档位都一样。
|
||||
# 划分依据(反编译源码):
|
||||
# * 计分类型 NoteScore.EScoreType 由 GamePlayManager.NoteType2ScoreType() 决定
|
||||
# (Break 系 -> Break,滑条 -> Slide,长按 -> Hold,其余 -> Tap/Touch)
|
||||
# * 判定时机表 NoteJudge.EJudgeType 由各音符类决定:
|
||||
# TapNote/StarNote -> Tap(或 ExNote 时 ExTap)
|
||||
# HoldNote/BreakHoldNote/TouchHoldC -> 长按结算 JudgeHoldTotal(不分 2nd/3rd 档)
|
||||
# SlideRoot/SlideFan -> SlideOut(不分 2nd/3rd 档,也没有非 Critical 的 Perfect)
|
||||
# BreakNote/BreakStarNote -> Break 表(Great 有 1250/1500/2000 三档)
|
||||
# * ExTap 判定表 [-9,-9,-9,-9,-9,-9,-9,0,9,9,9,9,9,9,9] 使非 Critical 的档位全部不可达
|
||||
# -> ExTap/ExStar(含 ExBreak 版)只能 Critical 或 Miss
|
||||
# * ExHold/ExBreakHold 的头部用 ExTap 表,但结果还要过 JudgeHoldTotal 重映射,
|
||||
# 最终仍可得到 Critical/Perfect/Great/Good/Miss -> 与普通 Hold / BreakHold 相同
|
||||
# * ExSlide / ExBreakSlide 的 JudgeType 恒为 SlideOut,且 isBreak() 不含 ExBreakSlide
|
||||
# -> 计分与普通 Slide 完全一致(游戏数据里这两种指令出现次数为 0)
|
||||
GROUP_TAP = "tap" # TAP NMTAP STR NMSTR TTP NMTTP 权重 500
|
||||
GROUP_HOLD = "hold" # HLD NMHLD XHO EXHLD THO NMTHO 权重 1000
|
||||
GROUP_SLIDE = "slide" # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_ + NM/EX/CN 变体 权重 1500
|
||||
GROUP_BREAK = "break" # BRK BRTAP BST BRSTR 权重 2500,Great 三档、Perfect 奖励两档
|
||||
GROUP_BREAK_FLAT = "break_flat" # BRHLD BXHLD 权重 2500,长按判定,不分档
|
||||
GROUP_BREAK_SLIDE = "break_slide" # BRSI_ BRSCL BRSCR BRSUL BRSUR BRSSL BRSSR BRSV_ BRSXL BRSXR BRSLL BRSLR BRSF_
|
||||
GROUP_EX = "ex" # XTP EXTAP XST EXSTR 权重 500,只能 Critical / Miss
|
||||
GROUP_EX_BREAK = "ex_break" # BXTAP BXSTR 权重 2500,只能 Critical / Miss
|
||||
|
||||
GROUP_ORDER = (GROUP_TAP, GROUP_HOLD, GROUP_SLIDE, GROUP_BREAK,
|
||||
GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, GROUP_EX_BREAK)
|
||||
GROUP_LABEL = {
|
||||
GROUP_TAP: "Tap/Star/Touch",
|
||||
GROUP_HOLD: "Hold",
|
||||
GROUP_SLIDE: "Slide",
|
||||
GROUP_BREAK: "Break",
|
||||
GROUP_BREAK_FLAT: "Break(长按判定)",
|
||||
GROUP_BREAK_SLIDE: "Break(滑条判定)",
|
||||
GROUP_EX: "EX Tap/Star",
|
||||
GROUP_EX_BREAK: "EX Break",
|
||||
}
|
||||
GROUP_WEIGHT = {
|
||||
GROUP_TAP: WEIGHT_TAP,
|
||||
GROUP_HOLD: WEIGHT_HOLD,
|
||||
GROUP_SLIDE: WEIGHT_SLIDE,
|
||||
GROUP_BREAK: WEIGHT_BREAK,
|
||||
GROUP_BREAK_FLAT: WEIGHT_BREAK,
|
||||
GROUP_BREAK_SLIDE: WEIGHT_BREAK,
|
||||
GROUP_EX: WEIGHT_TAP,
|
||||
GROUP_EX_BREAK: WEIGHT_BREAK,
|
||||
}
|
||||
GROUP_IS_BREAK = frozenset((GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK))
|
||||
|
||||
_GROUP_BY_DEF = {
|
||||
"Begin": GROUP_TAP, "Star": GROUP_TAP, "TouchTap": GROUP_TAP,
|
||||
"Hold": GROUP_HOLD, "ExHold": GROUP_HOLD, "TouchHold": GROUP_HOLD,
|
||||
"Slide": GROUP_SLIDE, "ExSlide": GROUP_SLIDE, "ExBreakSlide": GROUP_SLIDE,
|
||||
"Break": GROUP_BREAK, "BreakStar": GROUP_BREAK,
|
||||
"BreakHold": GROUP_BREAK_FLAT, "ExBreakHold": GROUP_BREAK_FLAT,
|
||||
"BreakSlide": GROUP_BREAK_SLIDE,
|
||||
"ExTap": GROUP_EX, "ExStar": GROUP_EX,
|
||||
"ExBreakTap": GROUP_EX_BREAK, "ExBreakStar": GROUP_EX_BREAK,
|
||||
}
|
||||
_CATEGORY_BY_GROUP = {
|
||||
GROUP_TAP: CATEGORY_TAP, GROUP_HOLD: CATEGORY_HOLD, GROUP_SLIDE: CATEGORY_SLIDE,
|
||||
GROUP_BREAK: CATEGORY_BREAK, GROUP_BREAK_FLAT: CATEGORY_BREAK,
|
||||
GROUP_BREAK_SLIDE: CATEGORY_BREAK, GROUP_EX: CATEGORY_TAP, GROUP_EX_BREAK: CATEGORY_BREAK,
|
||||
}
|
||||
|
||||
|
||||
def note_group(def_name: str) -> Optional[str]:
|
||||
"""NotesTypeID.Def 名 -> 音符组;接续滑条等不计分音符返回 None。"""
|
||||
return _GROUP_BY_DEF.get(def_name)
|
||||
|
||||
|
||||
def score_category(def_name: str) -> Optional[str]:
|
||||
"""复刻 GamePlayManager.NoteType2ScoreType() 的粗分类(Tap/Hold/Slide/Break)。"""
|
||||
grp = note_group(def_name)
|
||||
if grp is None:
|
||||
return None
|
||||
return _CATEGORY_BY_GROUP[grp]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 谱面对象
|
||||
@dataclass
|
||||
class Chart:
|
||||
path: str = "<memory>"
|
||||
command_counts: Dict[str, int] = field(default_factory=dict)
|
||||
category_counts: Dict[str, int] = field(default_factory=dict)
|
||||
group_counts: Dict[str, int] = field(default_factory=dict)
|
||||
connect_count: int = 0
|
||||
header: Dict[str, str] = field(default_factory=dict)
|
||||
totals: Dict[str, int] = field(default_factory=dict)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
# 外部补充的信息(游戏路径模式)
|
||||
music_id: Optional[int] = None
|
||||
difficulty_index: Optional[int] = None
|
||||
difficulty_name: Optional[str] = None
|
||||
song_name: Optional[str] = None
|
||||
level_text: Optional[str] = None
|
||||
max_notes_xml: Optional[int] = None
|
||||
|
||||
@property
|
||||
def total_notes(self) -> int:
|
||||
"""计分音符总数 N(不含接续滑条)。"""
|
||||
return sum(self.group_counts.get(g, 0) for g in GROUP_ORDER)
|
||||
|
||||
@property
|
||||
def declared_total(self) -> Optional[int]:
|
||||
return self.totals.get("T_NUM_ALL")
|
||||
|
||||
def counts(self) -> Dict[str, int]:
|
||||
"""按音符组统计(供计分模型使用)。"""
|
||||
return {g: self.group_counts.get(g, 0) for g in GROUP_ORDER}
|
||||
|
||||
def category_summary(self) -> Dict[str, int]:
|
||||
return {c: self.category_counts.get(c, 0) for c in CATEGORY_ORDER}
|
||||
|
||||
|
||||
def parse_ma2(path: str) -> Chart:
|
||||
with open(path, "rb") as fp:
|
||||
raw = fp.read()
|
||||
chart = parse_ma2_bytes(raw, path)
|
||||
return chart
|
||||
|
||||
|
||||
def parse_ma2_bytes(raw: bytes, path: str = "<memory>") -> Chart:
|
||||
chart = Chart(path=path)
|
||||
text = raw.decode("latin1")
|
||||
unknown = set()
|
||||
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 1:
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
cmd = parts[0]
|
||||
args = parts[1:]
|
||||
|
||||
def_name = NOTE_COMMAND_TYPES.get(cmd)
|
||||
if def_name is not None:
|
||||
chart.command_counts[cmd] = chart.command_counts.get(cmd, 0) + 1
|
||||
grp = note_group(def_name)
|
||||
if grp is None:
|
||||
chart.connect_count += 1
|
||||
else:
|
||||
chart.group_counts[grp] = chart.group_counts.get(grp, 0) + 1
|
||||
cat = score_category(def_name)
|
||||
chart.category_counts[cat] = chart.category_counts.get(cat, 0) + 1
|
||||
continue
|
||||
|
||||
if cmd.startswith("T_") or cmd.startswith("TTM_"):
|
||||
if args:
|
||||
try:
|
||||
chart.totals[cmd] = int(args[0])
|
||||
except ValueError:
|
||||
pass
|
||||
continue
|
||||
|
||||
if cmd not in chart.header and args:
|
||||
chart.header[cmd] = "\t".join(args)
|
||||
elif not args and cmd.isupper() and len(cmd) > 2:
|
||||
unknown.add(cmd)
|
||||
|
||||
declared = chart.declared_total
|
||||
if declared is not None and declared != chart.total_notes:
|
||||
chart.warnings.append(
|
||||
"T_NUM_ALL=%d 与解析出的计分音符数 %d 不一致" % (declared, chart.total_notes)
|
||||
)
|
||||
return chart
|
||||
|
||||
|
||||
# ------------------------------------------------------------ 游戏路径解析
|
||||
DIFFICULTY_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
|
||||
_DIFFICULTY_ALIASES = {
|
||||
"basic": 0, "bas": 0, "bsc": 0, "0": 0, "00": 0,
|
||||
"advanced": 1, "adv": 1, "1": 1, "01": 1,
|
||||
"expert": 2, "exp": 2, "2": 2, "02": 2,
|
||||
"master": 3, "mas": 3, "mst": 3, "3": 3, "03": 3,
|
||||
"remaster": 4, "re:master": 4, "remas": 4, "re": 4, "4": 4, "04": 4,
|
||||
}
|
||||
|
||||
|
||||
def parse_difficulty(text) -> int:
|
||||
if isinstance(text, int):
|
||||
if 0 <= text <= 4:
|
||||
return text
|
||||
raise ValueError("难度序号必须是 0~4")
|
||||
key = str(text).strip().lower()
|
||||
if key in _DIFFICULTY_ALIASES:
|
||||
return _DIFFICULTY_ALIASES[key]
|
||||
raise ValueError("无法识别的难度:%s(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER,"
|
||||
"或直接写名字)" % text)
|
||||
|
||||
|
||||
def find_music_dir(game_path: str) -> str:
|
||||
"""把「游戏根目录 / Sinmai_Data / StreamingAssets / A000」都归一化到 A000/music。"""
|
||||
p = os.path.abspath(os.path.expanduser(game_path))
|
||||
if not os.path.isdir(p):
|
||||
raise FileNotFoundError("路径不存在:%s" % p)
|
||||
if os.path.basename(p).lower() == "music" and os.path.basename(os.path.dirname(p)).upper() == "A000":
|
||||
return p
|
||||
candidates = [
|
||||
p,
|
||||
os.path.join(p, "music"),
|
||||
os.path.join(p, "A000", "music"),
|
||||
os.path.join(p, "StreamingAssets", "A000", "music"),
|
||||
os.path.join(p, "Sinmai_Data", "StreamingAssets", "A000", "music"),
|
||||
os.path.join(p, "Package", "Sinmai_Data", "StreamingAssets", "A000", "music"),
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.isdir(c) and os.path.basename(c).lower() == "music":
|
||||
return c
|
||||
raise FileNotFoundError("在 %s 下找不到 A000/music 目录" % p)
|
||||
|
||||
|
||||
def music_dir_path(music_dir: str, music_id: int) -> str:
|
||||
return os.path.join(music_dir, "music%06d" % music_id)
|
||||
|
||||
|
||||
def chart_path(music_dir: str, music_id: int, difficulty: int) -> str:
|
||||
return os.path.join(music_dir_path(music_dir, music_id), "%06d_%02d.ma2" % (music_id, difficulty))
|
||||
|
||||
|
||||
def parse_music_xml(path: str) -> Dict[str, object]:
|
||||
"""读取 Music.xml(只用标准库),取出曲名与各难度信息。"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
info: Dict[str, object] = {"notes": []}
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"error": str(exc), "notes": []}
|
||||
|
||||
def text_of(node, tag):
|
||||
child = node.find(tag)
|
||||
if child is None:
|
||||
return None
|
||||
if child.text and child.text.strip():
|
||||
return child.text.strip()
|
||||
sub = child.find("str")
|
||||
if sub is not None and sub.text and sub.text.strip():
|
||||
return sub.text.strip()
|
||||
return None
|
||||
|
||||
info["name"] = text_of(root, "name")
|
||||
info["artist"] = text_of(root, "artistName")
|
||||
info["genre"] = text_of(root, "genreName")
|
||||
info["bpm"] = text_of(root, "bpm")
|
||||
notes_data = root.find("notesData")
|
||||
notes = []
|
||||
if notes_data is not None:
|
||||
for node in notes_data.findall("Notes"):
|
||||
f = node.find("file/path")
|
||||
notes.append({
|
||||
"file": f.text.strip() if f is not None and f.text else None,
|
||||
"level": text_of(node, "level"),
|
||||
"levelDecimal": text_of(node, "levelDecimal"),
|
||||
"musicLevelID": text_of(node, "musicLevelID"),
|
||||
"maxNotes": text_of(node, "maxNotes"),
|
||||
"designer": text_of(node, "notesDesigner"),
|
||||
"isEnable": text_of(node, "isEnable"),
|
||||
})
|
||||
info["notes"] = notes
|
||||
return info
|
||||
|
||||
|
||||
def level_display(level, level_decimal) -> str:
|
||||
"""12 + levelDecimal 4 -> '12+';10 + 2 -> '10+';7 + 0 -> '7'。"""
|
||||
try:
|
||||
lv = int(level)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
try:
|
||||
dec = int(level_decimal or 0)
|
||||
except (TypeError, ValueError):
|
||||
dec = 0
|
||||
if dec >= 3:
|
||||
return "%d+" % lv
|
||||
if dec > 0:
|
||||
return "%d.%d" % (lv, dec)
|
||||
return "%d" % lv
|
||||
@@ -0,0 +1,949 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""舞萌DX 达成率 / DX 分计算模型(按音符组精确建模)。
|
||||
|
||||
达成率(source/Assembly-CSharp/Manager/GameScoreList.cs:1238-1310 CalcUpdate())::
|
||||
|
||||
Achivement = num / _allPerfectScore * 100 + num2 / _breakBonusScore
|
||||
|
||||
其中 num = Σ 普通得分 NoteScore.GetJudgeScore(timing, 计分类型)
|
||||
num2 = Σ 绝赞奖励分(只有绝赞音符有)NoteScore.GetJudgeScore(timing, BreakBonus)
|
||||
_allPerfectScore = Σ 每个音符的满权重(NotesReader.calcTotal())
|
||||
_breakBonusScore = 100 × 绝赞音符数
|
||||
满分 = 101.0000%(有绝赞)/ 100.0000%(无绝赞)。
|
||||
|
||||
DX 分(JudgeResultSt.UpdateScore + NoteJudge.ConvertJudge)::
|
||||
|
||||
DX = 3×CriticalPerfect + 2×Perfect + 1×Great(Good/Miss 不加分)
|
||||
|
||||
与音符类型无关,理论 DX = 3 × 计分音符数。
|
||||
|
||||
模型要点
|
||||
--------
|
||||
* 音符按「组」划分,同一组内计分行为完全一致(权重、可选判定、失分档位、绝赞奖励档位)。
|
||||
* 绝赞(Break 类)的 Great 有 3 档失分、Perfect 奖励有 2 档(75 / 50),滑条判定的绝赞
|
||||
没有 Perfect 档,长按判定的绝赞不分档;EX 判定(ExTap 表)只能 Critical 或 Miss。
|
||||
* 因此「给定各判定的总个数」并不能唯一确定达成率:达成率依赖这些判定落在哪些音符上。
|
||||
本模块用最小费用流求达成率的精确上下界,并可严格判定「能否恰好打出某个达成率」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from fractions import Fraction
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ma2 import (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX,
|
||||
GROUP_EX_BREAK, GROUP_HOLD, GROUP_LABEL, GROUP_ORDER,
|
||||
GROUP_SLIDE, GROUP_TAP, GROUP_WEIGHT, Chart)
|
||||
|
||||
# ------------------------------------------------------------------ 常量
|
||||
JUDGMENTS = ("cp", "perfect", "great", "good", "miss")
|
||||
JUDGE_LABEL = {
|
||||
"cp": "Critical Perfect",
|
||||
"perfect": "Perfect",
|
||||
"great": "Great",
|
||||
"good": "Good",
|
||||
"miss": "Miss",
|
||||
}
|
||||
DX_PER = {"cp": 3, "perfect": 2, "great": 1, "good": 0, "miss": 0}
|
||||
|
||||
#: 每个音符组里,某判定实际可能出现的 (失分, 绝赞奖励分) 组合。
|
||||
#: 失分 = 该音符满权重 - 实际得分;奖励分只对绝赞(计分类型 Break)音符存在。
|
||||
GROUP_OPTIONS: Dict[str, Dict[str, Tuple[Tuple[int, int], ...]]] = {
|
||||
GROUP_TAP: {
|
||||
"cp": ((0, 0),),
|
||||
"perfect": ((0, 0),),
|
||||
"great": ((100, 0),), # 400
|
||||
"good": ((250, 0),), # 250
|
||||
"miss": ((500, 0),), # 0
|
||||
},
|
||||
GROUP_HOLD: {
|
||||
"cp": ((0, 0),),
|
||||
"perfect": ((0, 0),),
|
||||
"great": ((200, 0),), # 800
|
||||
"good": ((500, 0),), # 500
|
||||
"miss": ((1000, 0),), # 0
|
||||
},
|
||||
GROUP_SLIDE: {
|
||||
"cp": ((0, 0),),
|
||||
"perfect": ((0, 0),),
|
||||
"great": ((300, 0),), # 1200
|
||||
"good": ((750, 0),), # 750
|
||||
"miss": ((1500, 0),), # 0
|
||||
},
|
||||
GROUP_BREAK: {
|
||||
"cp": ((0, 100),),
|
||||
"perfect": ((0, 75), (0, 50)), # FastPerfect/LatePerfect 与 2nd 档
|
||||
"great": ((500, 40), (1000, 40), (1250, 40)), # 2000 / 1500 / 1250
|
||||
"good": ((1500, 30),), # 1000
|
||||
"miss": ((2500, 0),), # 0
|
||||
},
|
||||
GROUP_BREAK_FLAT: {
|
||||
"cp": ((0, 100),),
|
||||
"perfect": ((0, 75),), # 长按判定没有 2nd 档
|
||||
"great": ((500, 40),), # 只有 2000 一档
|
||||
"good": ((1500, 30),),
|
||||
"miss": ((2500, 0),),
|
||||
},
|
||||
GROUP_BREAK_SLIDE: {
|
||||
"cp": ((0, 100),),
|
||||
# 滑条判定没有非 Critical 的 Perfect
|
||||
"great": ((500, 40), (1000, 40), (1250, 40)),
|
||||
"good": ((1500, 30),),
|
||||
"miss": ((2500, 0),),
|
||||
},
|
||||
GROUP_EX: {
|
||||
"cp": ((0, 0),),
|
||||
"miss": ((500, 0),), # ExTap 判定表只有 Critical / TooFast / TooLate
|
||||
},
|
||||
GROUP_EX_BREAK: {
|
||||
"cp": ((0, 100),),
|
||||
"miss": ((2500, 0),),
|
||||
},
|
||||
}
|
||||
|
||||
_BAD_JUDGMENTS = ("great", "good", "miss")
|
||||
|
||||
#: 坏音符落在该组时,对 (可出现的绝赞音符数 bad_b, 加权和 slo) 的贡献。
|
||||
#: slo = 2×bad_break + 3×bad_break_flat + 4×bad_break_slide + 4×bad_ex_break,
|
||||
#: 用于还原「绝赞音符里 cp/perfect 的奖励分取值范围」。
|
||||
_GROUP_BAD_STEP = {
|
||||
GROUP_BREAK: (1, 2),
|
||||
GROUP_BREAK_FLAT: (1, 3),
|
||||
GROUP_BREAK_SLIDE: (1, 4),
|
||||
GROUP_EX_BREAK: (1, 4),
|
||||
}
|
||||
|
||||
#: 精确模式严格验证的最大坏音符数(Miss+Good+Great 总和)。超过则只做区间判定。
|
||||
EXACT_MAX_BAD = 12 # 精确验证时最多考虑多少个坏音符(Miss+Good+Great)
|
||||
EXACT_TIME_BUDGET = 8.0 # 精确验证 DP 的时间预算(秒),超时退回区间模式
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 理论值
|
||||
@dataclass
|
||||
class Theory:
|
||||
"""一个谱面的计分理论值。"""
|
||||
|
||||
counts: Dict[str, int] # 音符组 -> 音符数
|
||||
weights: Dict[str, int] # 音符组 -> 单音符满权重
|
||||
all_perfect_score: int # _allPerfectScore
|
||||
break_bonus_score: int # _breakBonusScore = 100 × 绝赞音符数
|
||||
total_notes: int # 计分音符总数 N(不含接续滑条)
|
||||
max_dx: int # 理论 DX = 3N
|
||||
lattice: int # 所有单音符失分-奖励组合的最大公约数
|
||||
|
||||
@property
|
||||
def has_break(self) -> bool:
|
||||
return self.break_bonus_score > 0
|
||||
|
||||
@property
|
||||
def break_notes(self) -> int:
|
||||
"""绝赞音符总数(计分类型为 Break 的音符)。"""
|
||||
return sum(self.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||
|
||||
@property
|
||||
def denominator(self) -> int:
|
||||
"""达成率公式的分母:100 - Σc/D 中的 D。"""
|
||||
if self.break_bonus_score:
|
||||
return self.all_perfect_score * self.break_bonus_score
|
||||
return self.all_perfect_score
|
||||
|
||||
@property
|
||||
def max_achievement(self) -> Fraction:
|
||||
return Fraction(101 if self.break_bonus_score else 100)
|
||||
|
||||
@property
|
||||
def perfect_capacity(self) -> int:
|
||||
"""允许出现 Perfect 的绝赞音符数(Break 与 Break(长按判定))。"""
|
||||
return (self.counts.get(GROUP_BREAK, 0)
|
||||
+ self.counts.get(GROUP_BREAK_FLAT, 0))
|
||||
|
||||
|
||||
def _lattice_of(th: "Theory") -> int:
|
||||
g = 0
|
||||
for group in GROUP_ORDER:
|
||||
for opts in GROUP_OPTIONS[group].values():
|
||||
for loss, bonus in opts:
|
||||
g = math.gcd(g, _cost(th, loss, bonus))
|
||||
return g
|
||||
|
||||
|
||||
def theory_from_counts(counts: Dict[str, int]) -> Theory:
|
||||
"""由「音符组 -> 音符数」构造理论值。"""
|
||||
clean: Dict[str, int] = {}
|
||||
for key, value in (counts or {}).items():
|
||||
if key not in GROUP_OPTIONS:
|
||||
raise ValueError("未知的音符组:%r" % (key,))
|
||||
value = int(value or 0)
|
||||
if value < 0:
|
||||
raise ValueError("音符数不能为负:%s=%d" % (key, value))
|
||||
if value:
|
||||
clean[key] = value
|
||||
all_perfect = 0
|
||||
break_notes = 0
|
||||
total = 0
|
||||
for group in GROUP_ORDER:
|
||||
n = clean.get(group, 0)
|
||||
if not n:
|
||||
continue
|
||||
weight = GROUP_WEIGHT[group]
|
||||
all_perfect += weight * n
|
||||
total += n
|
||||
if group in _GROUP_BAD_STEP:
|
||||
break_notes += n
|
||||
th = Theory(
|
||||
counts={g: clean.get(g, 0) for g in GROUP_ORDER},
|
||||
weights={g: GROUP_WEIGHT[g] for g in GROUP_ORDER},
|
||||
all_perfect_score=all_perfect,
|
||||
break_bonus_score=100 * break_notes,
|
||||
total_notes=total,
|
||||
max_dx=3 * total,
|
||||
lattice=0,
|
||||
)
|
||||
th.lattice = _lattice_of(th)
|
||||
return th
|
||||
|
||||
|
||||
def theory_from_chart(chart: Chart) -> Theory:
|
||||
return theory_from_counts(chart.counts())
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 单音符代价
|
||||
def _cost(th: Theory, loss: int, bonus: int) -> int:
|
||||
"""把 (失分, 奖励分) 折算成一个整数代价 c,使达成率 = 100 - Σc / D。"""
|
||||
if th.break_bonus_score:
|
||||
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
|
||||
return 100 * loss
|
||||
|
||||
|
||||
def _edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
|
||||
opts = GROUP_OPTIONS.get(group, {}).get(judge)
|
||||
if not opts:
|
||||
return None
|
||||
values = [_cost(th, loss, bonus) for loss, bonus in opts]
|
||||
# want_max 表示「追求最大达成率」,而达成率 = 100 - Σc/D,所以要取最小代价。
|
||||
return min(values) if want_max else max(values)
|
||||
|
||||
|
||||
def _flex_edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
|
||||
"""与 _edge_cost 相同,但「好音符」(CP/Perfect)可以在 CP 与 Perfect 之间自由选择。
|
||||
|
||||
只在反向求解的上下界里用:用户没给 CP/Perfect 拆分时,这个选择会影响达成率
|
||||
(绝赞的 CP 奖励 100、Perfect 只有 75/50)。
|
||||
"""
|
||||
if judge == "cp":
|
||||
# 「其余音符」这一档:既可以是 Critical Perfect,也可以是 Perfect
|
||||
# (Perfect 只能落在绝赞音符上,交给流自己挑)
|
||||
opts = (tuple(GROUP_OPTIONS.get(group, {}).get("cp", ()))
|
||||
+ tuple(GROUP_OPTIONS.get(group, {}).get("perfect", ())))
|
||||
else:
|
||||
opts = GROUP_OPTIONS.get(group, {}).get(judge)
|
||||
if not opts:
|
||||
return None
|
||||
values = [_cost(th, loss, bonus) for loss, bonus in opts]
|
||||
return min(values) if want_max else max(values)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 最小费用流
|
||||
class _MinCostFlow:
|
||||
"""连续最短路 + 整条增广(边数很少,够快)。"""
|
||||
|
||||
def __init__(self, n: int) -> None:
|
||||
self.n = n
|
||||
self.graph: List[List[List[int]]] = [[] for _ in range(n)]
|
||||
|
||||
def add_edge(self, u: int, v: int, cap: int, cost: int) -> None:
|
||||
if cap <= 0:
|
||||
return
|
||||
self.graph[u].append([v, cap, cost, len(self.graph[v])])
|
||||
self.graph[v].append([u, 0, -cost, len(self.graph[u]) - 1])
|
||||
|
||||
def min_cost(self, s: int, t: int, need: int) -> Optional[int]:
|
||||
if need <= 0:
|
||||
return 0
|
||||
total = 0
|
||||
flow = 0
|
||||
INF = float("inf")
|
||||
while flow < need:
|
||||
dist = [INF] * self.n
|
||||
prev_v = [-1] * self.n
|
||||
prev_e = [-1] * self.n
|
||||
in_queue = [False] * self.n
|
||||
dist[s] = 0
|
||||
queue = deque([s])
|
||||
in_queue[s] = True
|
||||
while queue:
|
||||
u = queue.popleft()
|
||||
in_queue[u] = False
|
||||
du = dist[u]
|
||||
for i, edge in enumerate(self.graph[u]):
|
||||
v, cap, cost, _rev = edge
|
||||
if cap > 0 and du + cost < dist[v]:
|
||||
dist[v] = du + cost
|
||||
prev_v[v] = u
|
||||
prev_e[v] = i
|
||||
if not in_queue[v]:
|
||||
in_queue[v] = True
|
||||
queue.append(v)
|
||||
if dist[t] == INF:
|
||||
return None
|
||||
add = need - flow
|
||||
v = t
|
||||
while v != s:
|
||||
add = min(add, self.graph[prev_v[v]][prev_e[v]][1])
|
||||
v = prev_v[v]
|
||||
v = t
|
||||
while v != s:
|
||||
edge = self.graph[prev_v[v]][prev_e[v]]
|
||||
edge[1] -= add
|
||||
self.graph[v][edge[3]][1] += add
|
||||
v = prev_v[v]
|
||||
flow += add
|
||||
total += add * dist[t]
|
||||
return total
|
||||
|
||||
|
||||
def _extreme(th: Theory, judges: Dict[str, int], want_max: bool,
|
||||
flex_good: bool = False) -> Optional[Fraction]:
|
||||
"""在给定各判定总数的前提下,求达成率的极大值 / 极小值。
|
||||
|
||||
flex_good=True 时,「good」这一类里的 CP/Perfect 拆分由流自己挑
|
||||
(反向求解用:只给 Miss/Good/Great 时,CP 与 Perfect 的拆分是自由的)。
|
||||
"""
|
||||
total = sum(int(judges.get(j, 0)) for j in JUDGMENTS)
|
||||
if total != th.total_notes:
|
||||
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
|
||||
if total == 0:
|
||||
return Fraction(0)
|
||||
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0)]
|
||||
src = 0
|
||||
gbase = 1
|
||||
jbase = gbase + len(groups)
|
||||
sink = jbase + len(JUDGMENTS)
|
||||
mcf = _MinCostFlow(sink + 1)
|
||||
for i, group in enumerate(groups):
|
||||
mcf.add_edge(src, gbase + i, th.counts[group], 0)
|
||||
for j, judge in enumerate(JUDGMENTS):
|
||||
count = int(judges.get(judge, 0))
|
||||
if count:
|
||||
mcf.add_edge(jbase + j, sink, count, 0)
|
||||
for i, group in enumerate(groups):
|
||||
for j, judge in enumerate(JUDGMENTS):
|
||||
count = int(judges.get(judge, 0))
|
||||
if not count:
|
||||
continue
|
||||
cost = (_flex_edge_cost(th, group, judge, want_max) if flex_good
|
||||
else _edge_cost(th, group, judge, want_max))
|
||||
if cost is None:
|
||||
continue # 该判定不可能落在这类音符上:不连边,交给流去判定可行性
|
||||
# 最小费用流只会最小化总代价:求最小达成率时把代价取负,最后再翻回来。
|
||||
mcf.add_edge(gbase + i, jbase + j, min(th.counts[group], count),
|
||||
cost if want_max else -cost)
|
||||
total_cost = mcf.min_cost(src, sink, total)
|
||||
if total_cost is None:
|
||||
return None
|
||||
if not want_max:
|
||||
total_cost = -total_cost
|
||||
return Fraction(100) - Fraction(total_cost, th.denominator)
|
||||
|
||||
|
||||
def achievement_bounds(th: Theory, judges: Dict[str, int]) -> Tuple[Fraction, Fraction]:
|
||||
"""返回 (达成率下界, 达成率上界)。"""
|
||||
lo = _extreme(th, judges, want_max=False)
|
||||
hi = _extreme(th, judges, want_max=True)
|
||||
if lo is None or hi is None:
|
||||
raise ValueError("这些判定数无法落在该谱面上(例如把 Perfect 给了 EX 音符)")
|
||||
return lo, hi
|
||||
|
||||
|
||||
def reference_achievement(th: Theory, judges: Dict[str, int]) -> Fraction:
|
||||
"""按比例分摊的参考达成率:各判定按总数比例摊到每个音符组,档位取中值。"""
|
||||
n = th.total_notes
|
||||
if n == 0:
|
||||
return Fraction(0)
|
||||
loss = Fraction(0)
|
||||
bonus = Fraction(0)
|
||||
for group in GROUP_ORDER:
|
||||
count = th.counts.get(group, 0)
|
||||
if not count:
|
||||
continue
|
||||
for judge in JUDGMENTS:
|
||||
given = int(judges.get(judge, 0))
|
||||
if not given:
|
||||
continue
|
||||
opts = GROUP_OPTIONS[group].get(judge)
|
||||
if not opts:
|
||||
continue
|
||||
losses = sorted({o[0] for o in opts})
|
||||
bonuses = sorted({o[1] for o in opts})
|
||||
share = Fraction(given * count, n)
|
||||
loss += share * Fraction(losses[0] + losses[-1], 2)
|
||||
bonus += share * Fraction(bonuses[0] + bonuses[-1], 2)
|
||||
result = Fraction(100) - 100 * loss / th.all_perfect_score
|
||||
if th.break_bonus_score:
|
||||
result += bonus / th.break_bonus_score
|
||||
return result
|
||||
|
||||
|
||||
def dx_score(judges: Dict[str, int]) -> int:
|
||||
return sum(DX_PER[j] * int(judges.get(j, 0)) for j in JUDGMENTS)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 正向
|
||||
@dataclass
|
||||
class ForwardResult:
|
||||
theory: Theory
|
||||
judges: Dict[str, int]
|
||||
achievement_min: Fraction
|
||||
achievement_max: Fraction
|
||||
achievement_ref: Fraction
|
||||
dx: int
|
||||
|
||||
@property
|
||||
def exact(self) -> bool:
|
||||
return self.achievement_min == self.achievement_max
|
||||
|
||||
@property
|
||||
def achievement(self) -> Fraction:
|
||||
return self.achievement_min
|
||||
|
||||
|
||||
def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult:
|
||||
clean = {j: int(judges.get(j, 0) or 0) for j in JUDGMENTS}
|
||||
total = sum(clean.values())
|
||||
if total != th.total_notes:
|
||||
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
|
||||
lo, hi = achievement_bounds(th, clean)
|
||||
return ForwardResult(
|
||||
theory=th,
|
||||
judges=clean,
|
||||
achievement_min=lo,
|
||||
achievement_max=hi,
|
||||
achievement_ref=reference_achievement(th, clean),
|
||||
dx=dx_score(clean),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 正向:按音符类别精确计算
|
||||
# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间;
|
||||
# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。
|
||||
CLASS_ORDER = ("tap", "hold", "slide", "break")
|
||||
CLASS_LABEL = {
|
||||
"tap": "Tap/Star/Touch/EX",
|
||||
"hold": "Hold 系",
|
||||
"slide": "Slide 系",
|
||||
"break": "绝赞(Break 系)",
|
||||
}
|
||||
CLASS_WEIGHT = {"tap": 500, "hold": 1000, "slide": 1500, "break": 2500}
|
||||
CLASS_GROUPS = {
|
||||
"tap": (GROUP_TAP, GROUP_EX),
|
||||
"hold": (GROUP_HOLD,),
|
||||
"slide": (GROUP_SLIDE,),
|
||||
"break": (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK),
|
||||
}
|
||||
# 每类里每个判定键的 (失分, 奖励分);绝赞的 Great / Perfect 有多个档位
|
||||
CLASS_OPTIONS = {
|
||||
"tap": {"cp": (0, 0), "perfect": (0, 0), "great": (100, 0),
|
||||
"good": (250, 0), "miss": (500, 0)},
|
||||
"hold": {"cp": (0, 0), "perfect": (0, 0), "great": (200, 0),
|
||||
"good": (500, 0), "miss": (1000, 0)},
|
||||
"slide": {"cp": (0, 0), "perfect": (0, 0), "great": (300, 0),
|
||||
"good": (750, 0), "miss": (1500, 0)},
|
||||
"break": {"cp": (0, 100), "perfect": (0, 75), "perfect50": (0, 50),
|
||||
"great": (500, 40), "great40": (1000, 40), "great50": (1250, 40),
|
||||
"good": (1500, 30), "miss": (2500, 0)},
|
||||
}
|
||||
_JUDGE_OF_KEY = {
|
||||
"cp": "cp", "perfect": "perfect", "perfect50": "perfect",
|
||||
"great": "great", "great40": "great", "great50": "great",
|
||||
"good": "good", "miss": "miss",
|
||||
}
|
||||
|
||||
|
||||
def class_capacity(th: Theory, cls: str) -> int:
|
||||
"""这一类音符在谱面里有多少个。"""
|
||||
return sum(th.counts.get(g, 0) for g in CLASS_GROUPS[cls])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExactResult:
|
||||
theory: Theory
|
||||
spec: Dict[str, Dict[str, int]] # 实际参与计算的判定数(已补足 CP)
|
||||
achievement: Fraction
|
||||
dx: int
|
||||
loss_total: int
|
||||
bonus_total: int
|
||||
breakdown: Dict[str, Tuple[int, int, int]] # 类别 -> (音符数, 失分, 奖励分)
|
||||
|
||||
@property
|
||||
def score(self) -> int:
|
||||
return self.theory.all_perfect_score - self.loss_total
|
||||
|
||||
|
||||
def solve_exact(th: Theory, spec: Dict[str, Dict[str, int]]) -> ExactResult:
|
||||
"""按音符类别给判定数,算出**确切**的达成率与 DX 分。
|
||||
|
||||
spec: {"tap": {"cp": 250, "great": 2}, "break": {"cp": 4, "great": 1}, ...}
|
||||
每类没给满的部分按 CP 补足(非绝赞 CP 与 Perfect 同分;绝赞按 CP 计奖励 100)。
|
||||
"""
|
||||
filled: Dict[str, Dict[str, int]] = {}
|
||||
loss_total = 0
|
||||
bonus_total = 0
|
||||
dx = 0
|
||||
breakdown: Dict[str, Tuple[int, int, int]] = {}
|
||||
for cls in CLASS_ORDER:
|
||||
cap = class_capacity(th, cls)
|
||||
given = {k: int(v) for k, v in (spec.get(cls) or {}).items() if int(v)}
|
||||
for key in given:
|
||||
if key not in CLASS_OPTIONS[cls]:
|
||||
raise ValueError("「%s」没有「%s」这个判定" % (CLASS_LABEL[cls], key))
|
||||
used = sum(given.values())
|
||||
if used > cap:
|
||||
raise ValueError("%s 只有 %d 个音符,但给了 %d 个判定"
|
||||
% (CLASS_LABEL[cls], cap, used))
|
||||
if cap > used:
|
||||
given["cp"] = given.get("cp", 0) + (cap - used)
|
||||
filled[cls] = given
|
||||
cls_loss = 0
|
||||
cls_bonus = 0
|
||||
for key, n in given.items():
|
||||
dl, db = CLASS_OPTIONS[cls][key]
|
||||
cls_loss += dl * n
|
||||
cls_bonus += db * n
|
||||
dx += DX_PER[_JUDGE_OF_KEY[key]] * n
|
||||
loss_total += cls_loss
|
||||
bonus_total += cls_bonus
|
||||
breakdown[cls] = (cap, cls_loss, cls_bonus)
|
||||
|
||||
w = th.all_perfect_score
|
||||
if w <= 0:
|
||||
raise ValueError("谱面里没有计分音符")
|
||||
achievement = Fraction(100) - Fraction(100 * loss_total, w)
|
||||
if th.break_bonus_score:
|
||||
achievement += Fraction(bonus_total, th.break_bonus_score)
|
||||
return ExactResult(
|
||||
theory=th, spec=filled, achievement=achievement, dx=dx,
|
||||
loss_total=loss_total, bonus_total=bonus_total, breakdown=breakdown,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 反向:工具函数
|
||||
def _judges_for(th: Theory, miss: int, good: int, great: int,
|
||||
cp: int, perfect: int) -> Dict[str, int]:
|
||||
return {"cp": cp, "perfect": perfect, "great": great, "good": good, "miss": miss}
|
||||
|
||||
|
||||
def _best(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
|
||||
"""给定 Miss/Good/Great 数时,达成率能达到的最大值(CP/Perfect 拆分自由)。"""
|
||||
s = th.total_notes - miss - good - great
|
||||
if s < 0:
|
||||
return None
|
||||
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=True,
|
||||
flex_good=True)
|
||||
|
||||
|
||||
def _worst(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
|
||||
"""给定 Miss/Good/Great 数时,达成率能达到的最小值(CP/Perfect 拆分自由)。"""
|
||||
s = th.total_notes - miss - good - great
|
||||
if s < 0:
|
||||
return None
|
||||
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=False,
|
||||
flex_good=True)
|
||||
|
||||
|
||||
def _max_true(lo: int, hi: int, pred) -> int:
|
||||
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最大值(无则返回 lo-1)。"""
|
||||
best = lo - 1
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
if pred(mid):
|
||||
best = mid
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid - 1
|
||||
return best
|
||||
|
||||
|
||||
def _min_true(lo: int, hi: int, pred) -> int:
|
||||
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最小值(无则返回 hi+1)。"""
|
||||
best = hi + 1
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
if pred(mid):
|
||||
best = mid
|
||||
hi = mid - 1
|
||||
else:
|
||||
lo = mid + 1
|
||||
return best
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 反向:候选行
|
||||
def _candidate_rows(th: Theory, target: Fraction, max_scan: int,
|
||||
bad_limit: Optional[int] = None, stats: Optional[Dict] = None):
|
||||
"""生成「可能覆盖 target」的 (miss, good, great) 组合,顺序由少到多。
|
||||
|
||||
bad_limit 非空时只产出 Miss+Good+Great <= bad_limit 的组合,
|
||||
并在 stats["clipped"] 里记下「还有更多(坏音符更多的)组合没产出」。
|
||||
"""
|
||||
n = th.total_notes
|
||||
scanned = 0
|
||||
|
||||
def best_m(m):
|
||||
v = _best(th, m, 0, 0)
|
||||
return v is not None and v >= target
|
||||
|
||||
miss_max = _max_true(0, n, best_m)
|
||||
if miss_max < 0:
|
||||
return
|
||||
if bad_limit is not None and miss_max > bad_limit and stats is not None:
|
||||
stats["clipped"] = True
|
||||
miss_hi = miss_max if bad_limit is None else min(miss_max, bad_limit)
|
||||
for miss in range(miss_hi + 1):
|
||||
def best_g(g):
|
||||
v = _best(th, miss, g, 0)
|
||||
return v is not None and v >= target
|
||||
|
||||
good_max = _max_true(0, n - miss, best_g)
|
||||
if good_max < 0:
|
||||
continue
|
||||
if bad_limit is not None and good_max > bad_limit - miss:
|
||||
if stats is not None:
|
||||
stats["clipped"] = True
|
||||
good_max = bad_limit - miss
|
||||
for good in range(good_max + 1):
|
||||
room = n - miss - good
|
||||
|
||||
def best_gr(gr):
|
||||
v = _best(th, miss, good, gr)
|
||||
return v is not None and v >= target
|
||||
|
||||
def worst_gr(gr):
|
||||
v = _worst(th, miss, good, gr)
|
||||
return v is not None and v <= target
|
||||
|
||||
# 先求「Great 放得下」的最大个数:超过它整行不可行(例如 EX 音符不能判 Great),
|
||||
# 否则不可行的高端会让 worst_gr 不再单调,二分就会找错。
|
||||
feasible_hi = _max_true(0, room, lambda gr: _worst(th, miss, good, gr) is not None)
|
||||
if feasible_hi < 0:
|
||||
continue
|
||||
g_high = _max_true(0, feasible_hi, best_gr)
|
||||
g_low = _min_true(0, feasible_hi, worst_gr)
|
||||
if bad_limit is not None and g_high > bad_limit - miss - good:
|
||||
if stats is not None:
|
||||
stats["clipped"] = True
|
||||
g_high = bad_limit - miss - good
|
||||
if g_low > g_high:
|
||||
continue
|
||||
for great in range(g_low, g_high + 1):
|
||||
scanned += 1
|
||||
if scanned > max_scan:
|
||||
if stats is not None:
|
||||
stats["clipped"] = True
|
||||
return
|
||||
yield miss, good, great
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReverseRow:
|
||||
miss: int
|
||||
good: int
|
||||
great: int
|
||||
cp_plus_perfect: int
|
||||
cp_min: int
|
||||
cp_max: int
|
||||
achievement_min: Fraction
|
||||
achievement_max: Fraction
|
||||
dx_min: int = 0
|
||||
dx_max: int = 0
|
||||
verified: bool = True # 精确模式下是否已严格验证
|
||||
exact: bool = False # 是否恰好等于目标达成率
|
||||
|
||||
def summary(self) -> str:
|
||||
return ("miss %d · good %d · great %d · cp+perfect %d"
|
||||
% (self.miss, self.good, self.great, self.cp_plus_perfect))
|
||||
|
||||
|
||||
def _row_dx_bounds(th: Theory, row: ReverseRow) -> None:
|
||||
base = 2 * row.cp_plus_perfect + row.great
|
||||
row.dx_min = base + row.cp_min
|
||||
row.dx_max = base + row.cp_max
|
||||
|
||||
|
||||
def _cp_bounds_simple(th: Theory, s: int) -> Tuple[int, int]:
|
||||
"""cp 的物理取值范围:Perfect 只能出现在 Break / Break(长按判定) 上。"""
|
||||
cp_min = max(0, s - th.perfect_capacity)
|
||||
return cp_min, s
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 反向:区间模式
|
||||
def solve_reverse_range(th: Theory, target: Fraction, limit: int = 300,
|
||||
max_scan: int = 200000) -> Dict[str, object]:
|
||||
rows: List[ReverseRow] = []
|
||||
scanned = 0
|
||||
truncated = False
|
||||
for miss, good, great in _candidate_rows(th, target, max_scan):
|
||||
scanned += 1
|
||||
lo = _worst(th, miss, good, great)
|
||||
hi = _best(th, miss, good, great)
|
||||
if lo is None or hi is None:
|
||||
continue
|
||||
s = th.total_notes - miss - good - great
|
||||
cp_min, cp_max = _cp_bounds_simple(th, s)
|
||||
row = ReverseRow(
|
||||
miss=miss, good=good, great=great, cp_plus_perfect=s,
|
||||
cp_min=cp_min, cp_max=cp_max,
|
||||
achievement_min=lo, achievement_max=hi,
|
||||
)
|
||||
_row_dx_bounds(th, row)
|
||||
rows.append(row)
|
||||
if len(rows) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
return {
|
||||
"target": target,
|
||||
"mode": "range",
|
||||
"theory": th,
|
||||
"rows": rows,
|
||||
"truncated": truncated,
|
||||
"scanned": scanned,
|
||||
"exact_cap": EXACT_MAX_BAD,
|
||||
"unverified": 0,
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ 反向:精确模式
|
||||
def _bad_options(group: str) -> Dict[str, Tuple[Tuple[int, int], ...]]:
|
||||
return {j: GROUP_OPTIONS[group][j] for j in _BAD_JUDGMENTS
|
||||
if j in GROUP_OPTIONS[group]}
|
||||
|
||||
|
||||
def _dx_delta(th: Theory, loss: int, bonus: int) -> int:
|
||||
"""一个坏音符对「目标量 X」的贡献;X = rhs 等价于达成率等于目标。"""
|
||||
if th.break_bonus_score:
|
||||
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
|
||||
return 100 * loss
|
||||
|
||||
|
||||
def _exact_suffix(th: Theory, groups: List[str]) -> List[Tuple[int, int, int, int]]:
|
||||
"""每个后缀(含自身)的 (dX 负向下界, dX 上界, slo 上界, bad 上界),用于剪枝。
|
||||
|
||||
注意 dX 下界必须按「最多只有这些音符是坏音符、也可以是 0 个」来算,
|
||||
所以只累计负的贡献(min(0, ...)·个数),否则会错误剪掉合法解。
|
||||
"""
|
||||
suf: List[Tuple[int, int, int, int]] = [(0, 0, 0, 0)] * (len(groups) + 1)
|
||||
for i in range(len(groups) - 1, -1, -1):
|
||||
group = groups[i]
|
||||
n = th.counts.get(group, 0)
|
||||
opts = _bad_options(group)
|
||||
dxs = [_dx_delta(th, dl, db)
|
||||
for choices in opts.values() for dl, db in choices]
|
||||
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
|
||||
pl, ph, ps, pn = suf[i + 1]
|
||||
suf[i] = (pl + n * min(0, min(dxs)), ph + n * max(dxs), ps + n * d_slo,
|
||||
pn + n * (1 if d_bad else 0))
|
||||
return suf
|
||||
|
||||
|
||||
def _exact_table(th: Theory, max_bad: int, rhs: Fraction,
|
||||
set_cap: int = 20000, total_cap: int = 1500000,
|
||||
deadline: Optional[float] = None):
|
||||
"""DP:状态 (miss, good, great) -> 可达的 (X, bad_b, slo) 集合。
|
||||
|
||||
X = 100*B*失分和 - W*坏音符奖励分(无绝赞时 X = 100*失分和),与达成率一一对应;
|
||||
只统计「坏音符」(Great/Good/Miss);绝赞音符里 cp/perfect 的奖励分用 slo 还原成区间。
|
||||
每个候选元组都用「剩余音符最大/最小贡献」做目标剪枝(只对当前 rhs 有效)。
|
||||
超出预算时返回 None(调用方退回区间判定)。
|
||||
"""
|
||||
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0) and _bad_options(g)]
|
||||
suf = _exact_suffix(th, groups)
|
||||
w = th.all_perfect_score
|
||||
b = th.break_bonus_score
|
||||
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
|
||||
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
|
||||
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
|
||||
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
|
||||
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||
|
||||
table: Dict[Tuple[int, int, int], set] = {(0, 0, 0): {(0, 0, 0)}}
|
||||
for gi, group in enumerate(groups):
|
||||
n = th.counts.get(group, 0)
|
||||
opts = _bad_options(group)
|
||||
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
|
||||
deltas = {j: [_dx_delta(th, dl, db) for dl, db in choices]
|
||||
for j, choices in opts.items()}
|
||||
dxmin_g = min(v for vs in deltas.values() for v in vs)
|
||||
dxmax_g = max(v for vs in deltas.values() for v in vs)
|
||||
rest_after = suf[gi + 1]
|
||||
slots = min(n, max_bad)
|
||||
for k in range(slots):
|
||||
if deadline is not None and time.monotonic() > deadline:
|
||||
return None
|
||||
new: Dict[Tuple[int, int, int], set] = {}
|
||||
for state, tset in table.items():
|
||||
carried = new.setdefault(state, set())
|
||||
carried.update(tset)
|
||||
if len(carried) > set_cap:
|
||||
return None
|
||||
if state[0] + state[1] + state[2] >= max_bad:
|
||||
continue
|
||||
for judge, vs in deltas.items():
|
||||
nxt = (state[0] + (1 if judge == "miss" else 0),
|
||||
state[1] + (1 if judge == "good" else 0),
|
||||
state[2] + (1 if judge == "great" else 0))
|
||||
bucket = new.setdefault(nxt, set())
|
||||
rem = min(slots - k - 1, max_bad - sum(nxt))
|
||||
dmin = rem * min(0, dxmin_g) + rest_after[0]
|
||||
dmax = rem * dxmax_g + rest_after[1]
|
||||
slomax = rem * d_slo + rest_after[2]
|
||||
if b:
|
||||
need_lo = rhs + 25 * w * (klo_const - slomax)
|
||||
need_hi = rhs + 25 * w * 4 * break_notes
|
||||
for x, bad_b, slo in tset:
|
||||
cbb = bad_b + d_bad
|
||||
cs = slo + d_slo
|
||||
for dx in vs:
|
||||
cx = x + dx
|
||||
if b:
|
||||
if cx + dmax < need_lo - 25 * w * cs:
|
||||
continue
|
||||
if cx + dmin > need_hi - 25 * w * 4 * cbb:
|
||||
continue
|
||||
else:
|
||||
if cx + dmax < rhs or cx + dmin > rhs:
|
||||
continue
|
||||
bucket.add((cx, cbb, cs))
|
||||
if len(bucket) > set_cap:
|
||||
return None
|
||||
table = new
|
||||
if sum(len(s) for s in table.values()) > total_cap:
|
||||
return None
|
||||
return table
|
||||
|
||||
|
||||
def _exact_row_ok(th: Theory, table, miss: int, good: int, great: int,
|
||||
rhs: Fraction) -> bool:
|
||||
"""严格判定:能否把 (miss, good, great) 判定恰好打出达成率 target。"""
|
||||
tset = table.get((miss, good, great))
|
||||
if not tset:
|
||||
return False
|
||||
w = th.all_perfect_score
|
||||
b = th.break_bonus_score
|
||||
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
|
||||
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
|
||||
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
|
||||
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
|
||||
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||
if not b:
|
||||
# 没有绝赞:达成率只由失分决定,100 * 失分和 == 分母 * (100 - 目标)
|
||||
for x, _bad_b, _slo in tset:
|
||||
if x == rhs:
|
||||
return True
|
||||
return False
|
||||
for x, bad_b, slo in tset:
|
||||
num = x - rhs
|
||||
if num % (25 * w):
|
||||
continue
|
||||
k = num // (25 * w)
|
||||
if klo_const - slo <= k <= 4 * (break_notes - bad_b):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def solve_reverse_exact(th: Theory, target: Fraction, limit: int = 300,
|
||||
max_scan: int = 200000) -> Dict[str, object]:
|
||||
d = th.denominator
|
||||
rhs = Fraction(d) * (100 - target)
|
||||
note = None
|
||||
table = None
|
||||
max_bad_used = 0
|
||||
pending: List[Tuple[int, int, int]] = []
|
||||
scanned = 0
|
||||
|
||||
if rhs.denominator != 1:
|
||||
note = ("该达成率不是本谱面能精确打出的值(达成率只能取 100 - 整数/分母 的形式),"
|
||||
"下面给出区间判定结果。")
|
||||
elif th.lattice and rhs.numerator % th.lattice:
|
||||
note = ("该达成率不满足谱面的失分格点(失分只能是 %d 的倍数),"
|
||||
"下面给出区间判定结果。" % th.lattice)
|
||||
stats: Dict[str, bool] = {}
|
||||
if note is None:
|
||||
# 只枚举「坏音符不超过 EXACT_MAX_BAD」的组合:超出的组合没法精确验证,
|
||||
# 全枚举它们会白花很多时间(大谱面可能有几十万组)。
|
||||
for miss, good, great in _candidate_rows(th, target, max_scan,
|
||||
EXACT_MAX_BAD, stats):
|
||||
scanned += 1
|
||||
pending.append((miss, good, great))
|
||||
if pending:
|
||||
max_bad_used = min(max(sum(p) for p in pending), EXACT_MAX_BAD)
|
||||
table = _exact_table(th, max_bad_used, rhs,
|
||||
deadline=time.monotonic() + EXACT_TIME_BUDGET)
|
||||
if table is None:
|
||||
note = ("判定数较大,超出精确验证的预算(最多 %d 个 Miss+Good+Great),"
|
||||
"下面给出区间判定结果。" % EXACT_MAX_BAD)
|
||||
if note is not None:
|
||||
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||
fallback["note"] = note
|
||||
fallback["exact_cap"] = max_bad_used or EXACT_MAX_BAD
|
||||
fallback["more_unverified"] = bool(stats.get("clipped"))
|
||||
return fallback
|
||||
|
||||
if not pending and stats.get("clipped"):
|
||||
# 一组候选都没法精确验证(坏音符都超过上限),退回区间模式并说明原因
|
||||
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||
fallback["note"] = ("这个谱面/达成率下,坏音符(Miss+Good+Great)超过 %d 个的组合太多,"
|
||||
"精确验证代价过高,下面给出区间模式结果。" % EXACT_MAX_BAD)
|
||||
fallback["exact_cap"] = EXACT_MAX_BAD
|
||||
fallback["more_unverified"] = True
|
||||
return fallback
|
||||
|
||||
rows: List[ReverseRow] = []
|
||||
truncated = False
|
||||
unverified = 0
|
||||
for miss, good, great in pending:
|
||||
bad = miss + good + great
|
||||
if bad > max_bad_used:
|
||||
unverified += 1
|
||||
continue
|
||||
if not _exact_row_ok(th, table, miss, good, great, rhs):
|
||||
continue
|
||||
s = th.total_notes - bad
|
||||
cp_min, cp_max = _cp_bounds_simple(th, s)
|
||||
row = ReverseRow(
|
||||
miss=miss, good=good, great=great, cp_plus_perfect=s,
|
||||
cp_min=cp_min, cp_max=cp_max,
|
||||
achievement_min=target, achievement_max=target,
|
||||
verified=True, exact=True,
|
||||
)
|
||||
_row_dx_bounds(th, row)
|
||||
rows.append(row)
|
||||
if len(rows) >= limit:
|
||||
truncated = True
|
||||
break
|
||||
return {
|
||||
"target": target,
|
||||
"mode": "exact",
|
||||
"theory": th,
|
||||
"rows": rows,
|
||||
"truncated": truncated,
|
||||
"scanned": scanned,
|
||||
"exact_cap": max_bad_used,
|
||||
"unverified": unverified,
|
||||
"more_unverified": bool(stats.get("clipped")),
|
||||
"note": None,
|
||||
}
|
||||
|
||||
|
||||
def solve_reverse(th: Theory, target: Fraction, limit: int = 300,
|
||||
max_scan: int = 200000, mode: str = "exact") -> Dict[str, object]:
|
||||
target = Fraction(target)
|
||||
if target > th.max_achievement:
|
||||
raise ValueError("目标达成率 %s 超过该谱面上限 %s"
|
||||
% (target, th.max_achievement))
|
||||
if mode == "range":
|
||||
return solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||
if mode != "exact":
|
||||
raise ValueError("mode 只能是 exact 或 range")
|
||||
return solve_reverse_exact(th, target, limit=limit, max_scan=max_scan)
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
unlockmusic.py —— 主脚本:扫码登录 → 解锁(解禁)歌曲 → 登出
|
||||
================================================================================
|
||||
|
||||
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
|
||||
扫码 → 预览 → 登录 → UpsertUserAll → 落库回查 → 登出,一模一样。
|
||||
唯一多出来的东西是"要解锁哪些歌"这个入参。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
为什么解锁不用单独的接口
|
||||
--------------------------------------------------------------------------------
|
||||
游戏里解锁不是一次独立的网络请求,而是**往 userItemList 里补几条 UserItem**:
|
||||
|
||||
itemKind = 5 歌曲本体解锁(MusicUnlockList)
|
||||
itemKind = 6 Master 难度解锁(MusicUnlockMasterList)
|
||||
itemKind = 7 Re:Master 难度解锁(MusicUnlockReMasterList)
|
||||
|
||||
依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
|
||||
(UserData.cs:132-138 那四个解锁列表不是独立节点,是并进 userItemList 的)。
|
||||
|
||||
条目长这样:
|
||||
|
||||
{"itemKind": 5, "itemId": 11414, "stock": 1, "isValid": true}
|
||||
|
||||
★ 标准谱(musicId < 10000)的 Master / Re:Master **天生解锁**,不会写 kind6/7
|
||||
(source/Assembly-CSharp/Manager/NotesListManager.cs:415/455 开头就是
|
||||
`if (id < 10000) return true;`);只有 DX 谱(>= 10000)才需要额外解禁。
|
||||
★ 服务器上已经有的条目**自动跳过** —— 重复插入会让服务端直接 HTTP 500。
|
||||
★ Re:Master 还受活动开关约束(IsUnlockReMaster 里的 IsOpenEvent),
|
||||
那是机台侧开关,脚本改不了;写了也可能进游戏仍是灰的。
|
||||
★ 本功能**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
python unlockmusic.py <二维码> --music 11414 # 预检,不发包
|
||||
python unlockmusic.py <二维码> --music 11414 --send # ★ 真实解锁
|
||||
python unlockmusic.py <二维码> --music 1085,11414 --send # 多首,逗号分隔
|
||||
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-remaster
|
||||
|
||||
# 只要歌曲本体,不解禁任何难度
|
||||
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-master --no-unlock-remaster
|
||||
|
||||
# 和其它主脚本一样的通用开关
|
||||
python unlockmusic.py <二维码> --music 11414 --send --debug
|
||||
python unlockmusic.py <二维码> --music 11414 --send --cooldown 0
|
||||
python unlockmusic.py <二维码> --music 11414 --send --no-verify
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
|
||||
| `--send` | 真正发包(不加就是预检,零副作用) |
|
||||
| `--no-unlock-master` | 不解禁 Master |
|
||||
| `--no-unlock-remaster` | 不解禁 Re:Master |
|
||||
| `--cooldown N` | 发包前冷却秒数,默认取 config 的 15 秒 |
|
||||
| `--no-verify` | 跳过上传后的落库回查 |
|
||||
| `--debug` | 本次运行打印完整收发报文 |
|
||||
|
||||
日志同样是 logs/latest.log(覆盖)+ logs/YYYY-MM-DD.log(追加)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import upsert
|
||||
from api import log_error, setup_logging, teardown_logging
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
|
||||
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
|
||||
"cooldown": None, "extra_data": [],
|
||||
"unlock_music": [], "unlock_master": True, "unlock_remaster": True}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--no-verify":
|
||||
opts["no_verify"] = True
|
||||
elif a == "--extra-data" and i + 1 < len(argv):
|
||||
opts["extra_data"].append(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--cooldown" and i + 1 < len(argv):
|
||||
opts["cooldown"] = float(argv[i + 1])
|
||||
i += 1
|
||||
elif a in ("--music", "--unlock-music") and i + 1 < len(argv):
|
||||
# 可重复;也支持逗号分隔(中英文逗号都认)
|
||||
for part in str(argv[i + 1]).replace(",", ",").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
opts["unlock_music"].append(int(part))
|
||||
except ValueError:
|
||||
opts["unlock_bad"] = part
|
||||
i += 1
|
||||
elif a == "--no-unlock-master":
|
||||
opts["unlock_master"] = False
|
||||
elif a == "--no-unlock-remaster":
|
||||
opts["unlock_remaster"] = False
|
||||
elif not a.startswith("--") and not opts["qr"]:
|
||||
opts["qr"] = a
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["qr"] or not opts["unlock_music"]:
|
||||
# 没有二维码、或者没有指定要解锁哪首 —— 直接把用法打出来
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts.get("unlock_bad"):
|
||||
# 参数本身就不对,连扫码都不用做
|
||||
setup_logging("unlockmusic")
|
||||
log_error("--music 里有非法 musicId:" + str(opts["unlock_bad"]))
|
||||
teardown_logging()
|
||||
return 6
|
||||
|
||||
# ★ 整个流程复用 upsert.run(),这里只多传三个解锁参数
|
||||
return upsert.run(
|
||||
opts,
|
||||
unlock_music=opts["unlock_music"],
|
||||
unlock_master=opts["unlock_master"],
|
||||
unlock_remaster=opts["unlock_remaster"],
|
||||
log_name="unlockmusic",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
|
||||
================================================================================
|
||||
|
||||
整条流程只有四步,而且**一行日志都不用自己写** ——
|
||||
每个 api 函数被调用时会自动打印:
|
||||
|
||||
=== UserLogin 开始运行 ===
|
||||
[接口说明] 用户正式登录(开启本次登录会话)。
|
||||
[位置参数] 12345678, 'iUfgRmjr...'
|
||||
[关键字参数] region_id=1, place_id=1000
|
||||
=== UserLogin 运行结束 ===
|
||||
|
||||
日志模式改 config.py 一行:
|
||||
|
||||
LOG_LEVEL = "info" 只展示 api 脚本的运行情况和结果
|
||||
LOG_LEVEL = "debug" 额外展示向 api 发送的数据与 api 返回的数据
|
||||
|
||||
日志文件(两种同时写):
|
||||
|
||||
logs/latest.log 只含**当次**主脚本运行(下次覆盖)
|
||||
logs/YYYY-MM-DD.log 存放**当日**产生的所有日志(追加)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
|
||||
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
|
||||
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
|
||||
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 15 秒冷却
|
||||
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
|
||||
|
||||
# ★ 解锁(解禁)歌曲请用另一个主脚本(它复用本脚本的整套流程)
|
||||
python unlockmusic.py <二维码> --send --music 11414
|
||||
|
||||
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
|
||||
python upsert.py <二维码> --send --extra-data extra.json
|
||||
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
|
||||
python upsert.py <二维码> --send --extra-data a.json --extra-data b.json
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ UpsertUserAll 一个函数就把整条结算链路跑完了
|
||||
--------------------------------------------------------------------------------
|
||||
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
|
||||
2. 从快照推本局 5 只角色(游玩前等级)
|
||||
3. 构造本局成绩明细 userPlaylogList
|
||||
4. 调前置接口 GetUserNewItemListApi
|
||||
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
|
||||
6. 合并 extra_data 里的外部 JSON
|
||||
7. 发包前自检
|
||||
8. 冷却 -> 真实发包
|
||||
9. 落库回查(重新拉服务器数据,确认真的写进去了)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ --extra-data:额外塞节点
|
||||
--------------------------------------------------------------------------------
|
||||
想手工覆盖 / 补上某几个节点(比如本局拿到的新 KaleidxScope 门)时用:
|
||||
|
||||
# ① 从文件读(★ 推荐,不用跟 shell 引号搏斗)
|
||||
python upsert.py <二维码> --send --extra-data extra.json
|
||||
|
||||
# ② 直接写 JSON 字符串
|
||||
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
|
||||
|
||||
# ③ 多个来源,后给的覆盖先给的
|
||||
python upsert.py <二维码> --send --extra-data base.json --extra-data patch.json
|
||||
|
||||
extra.json 长这样:
|
||||
|
||||
{
|
||||
"userKaleidxScopeList": [
|
||||
{"gateId": 1, "isGateFound": true, "isKeyFound": true, "isClear": false,
|
||||
"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,
|
||||
"bestAchievement": 0, "bestDeluxscore": 0,
|
||||
"bestAchievementDate": "", "bestDeluxscoreDate": "",
|
||||
"playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false}
|
||||
],
|
||||
"isNewKaleidxScopeList": "1"
|
||||
}
|
||||
|
||||
★ 键必须是 upsertUserAll 的 36 个节点之一(含 11 条 isNew* 位串),
|
||||
写错会明确报错并列出可用节点名。
|
||||
★ 没给对应位串时会按数组长度自动补 "0" 并提示 ——
|
||||
新增条目请显式传 "1","0" 表示存量更新。
|
||||
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 这个文件同时是一个可复用的模块
|
||||
--------------------------------------------------------------------------------
|
||||
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
|
||||
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
|
||||
|
||||
import upsert
|
||||
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
|
||||
|
||||
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
|
||||
|
||||
── 在代码里直接调 api 的等价写法 ──
|
||||
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
extra_data={"userKaleidxScopeList": [...], "isNewKaleidxScopeList": "1"},
|
||||
confirm=True,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_info,
|
||||
log_warning,
|
||||
log_error,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
UpsertUserAll,
|
||||
ConfirmRequiredError,
|
||||
load_extra_data,
|
||||
PatchError,
|
||||
NODE_ORDER,
|
||||
NODE_TYPES,
|
||||
)
|
||||
from config import PLACE_ID, REGION_ID
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
|
||||
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
|
||||
"cooldown": None, "extra_data": []}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--no-verify":
|
||||
opts["no_verify"] = True
|
||||
elif a == "--extra-data" and i + 1 < len(argv):
|
||||
# 可重复;值是 JSON 字符串或 JSON 文件路径
|
||||
opts["extra_data"].append(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--cooldown" and i + 1 < len(argv):
|
||||
opts["cooldown"] = float(argv[i + 1])
|
||||
i += 1
|
||||
elif not a.startswith("--") and not opts["qr"]:
|
||||
opts["qr"] = a
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def run(opts, *, unlock_music=None, unlock_master=True, unlock_remaster=True,
|
||||
log_name="upsert") -> int:
|
||||
"""跑完整套流程。upsert.py 自己用,unlockmusic.py 也复用它。
|
||||
|
||||
opts 里必须有的键:qr / send / debug / no_verify / cooldown / extra_data
|
||||
unlock_music / unlock_master / unlock_remaster 三个是解锁用的,upsert.py 不传。
|
||||
"""
|
||||
if not opts.get("qr"):
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
# --debug 只影响本次运行,不改 config.py
|
||||
if opts.get("debug"):
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
|
||||
setup_logging(log_name)
|
||||
|
||||
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
|
||||
extra = None
|
||||
if opts["extra_data"]:
|
||||
merged = {}
|
||||
try:
|
||||
for item in opts["extra_data"]:
|
||||
part = load_extra_data(item)
|
||||
merged.update(part)
|
||||
log_info("--extra-data 读入 " + str(len(part)) + " 个节点:" + ", ".join(part))
|
||||
except PatchError as exc:
|
||||
log_error("--extra-data 解析失败:" + str(exc))
|
||||
return 6
|
||||
|
||||
# 节点名也在这里查(不然要等到登录后组包时才报错,白跑一趟)
|
||||
unknown = [k for k in merged if k not in NODE_TYPES]
|
||||
if unknown:
|
||||
log_error("--extra-data 里有未知节点名:" + ", ".join(unknown))
|
||||
log_error(" 可用节点(" + str(len(NODE_ORDER)) + " 个):" + ", ".join(NODE_ORDER))
|
||||
return 6
|
||||
|
||||
if not merged:
|
||||
log_warning("--extra-data 解析出来是空的,忽略")
|
||||
else:
|
||||
extra = merged
|
||||
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
|
||||
|
||||
# ---- 解锁参数校验(同样放在扫码之前)----
|
||||
if unlock_music:
|
||||
log_info("要解锁 " + str(len(unlock_music)) + " 首:"
|
||||
+ ", ".join(str(m) for m in unlock_music)
|
||||
+ "(Master=" + str(unlock_master)
|
||||
+ " / Re:Master=" + str(unlock_remaster) + ")")
|
||||
|
||||
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---- 扫码:二维码 -> userId + token ----
|
||||
user_id, token = ParseQRCode(opts["qr"])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
|
||||
preview = GetUserPreview(user_id, token)
|
||||
if preview.isLogin:
|
||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
|
||||
# ---- 登录:拿到 playlogId 与 loginDateTime(结算上报要用)----
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
# ---- 结算:★ 一个函数搞定构造 + 发包 + 落库回查 ----
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
extra_data=extra,
|
||||
unlock_music_ids=unlock_music or None,
|
||||
unlock_master=unlock_master,
|
||||
unlock_remaster=unlock_remaster,
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
cooldown_sec=opts["cooldown"],
|
||||
verify_landing=not opts["no_verify"],
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
return 0
|
||||
|
||||
except ConfirmRequiredError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
# 兜底登出:登录过且还没登出就释放会话,否则账号卡在 isLogin=True
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run(parse_args(sys.argv[1:]), log_name="upsert")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
|
||||
================================================================================
|
||||
|
||||
一条完整的「用票」链路:
|
||||
|
||||
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
|
||||
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么可以用 UpsertUserAll 消耗票券
|
||||
--------------------------------------------------------------------------------
|
||||
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
|
||||
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
|
||||
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
|
||||
服务端就会认为这张票被这一局用掉了。
|
||||
|
||||
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
|
||||
|
||||
userChargeList: [
|
||||
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
|
||||
{"chargeId":3,"stock":0,...},
|
||||
{"chargeId":5,"stock":0,...}
|
||||
]
|
||||
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
|
||||
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
|
||||
userPlaylogList[0].playCredit = 2
|
||||
userData.lastPlayCredit = 2
|
||||
|
||||
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
|
||||
|
||||
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
|
||||
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
|
||||
|
||||
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
|
||||
creditNum 表见 TICKET_CREDIT_NUM。
|
||||
|
||||
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
|
||||
所以这里也不需要给位串。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
python use_ticket.py <二维码> # 预检:只看报文,不发包
|
||||
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
|
||||
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
|
||||
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
|
||||
python use_ticket.py <二维码> --send --music-id 143 --level 4
|
||||
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
|
||||
|
||||
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
|
||||
★ 二维码**有效期 10 分钟**。
|
||||
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
log_error,
|
||||
log_info,
|
||||
log_warning,
|
||||
ParseQRCode,
|
||||
GetUserPreview,
|
||||
UserLogin,
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
GetUserCharge,
|
||||
GetNeedUserData,
|
||||
summarize_snapshot,
|
||||
UpsertUserAll,
|
||||
PlayData,
|
||||
ConfirmRequiredError,
|
||||
ticket_name,
|
||||
Ping,
|
||||
# ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
|
||||
TICKET_CREDIT_NUM,
|
||||
)
|
||||
from config import (
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
PLAY_MUSIC_ID,
|
||||
PLAY_MUSIC_LEVEL,
|
||||
PLAY_CREDIT,
|
||||
)
|
||||
|
||||
|
||||
def read_charges(user_id, label):
|
||||
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
|
||||
raw = GetUserCharge(user_id)
|
||||
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
|
||||
if not lst:
|
||||
log_info(" (空)")
|
||||
for c in lst:
|
||||
cid = c.get("chargeId")
|
||||
stock = int(c.get("stock", 0) or 0)
|
||||
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
|
||||
+ " stock=" + str(stock)
|
||||
+ " purchaseDate=" + str(c.get("purchaseDate"))
|
||||
+ " validDate=" + str(c.get("validDate"))
|
||||
+ (" ← ★ 持有中" if stock > 0 else ""))
|
||||
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
|
||||
return lst, held
|
||||
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
|
||||
"debug": False, "skip_entry": False,
|
||||
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--send":
|
||||
opts["send"] = True
|
||||
elif a == "--list":
|
||||
opts["list"] = True
|
||||
elif a == "--debug":
|
||||
opts["debug"] = True
|
||||
elif a == "--skip-entry":
|
||||
opts["skip_entry"] = True
|
||||
elif a == "--ticket-id" and i + 1 < len(argv):
|
||||
opts["ticket_id"] = int(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--music-id" and i + 1 < len(argv):
|
||||
opts["music_id"] = int(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--level" and i + 1 < len(argv):
|
||||
opts["level"] = int(argv[i + 1])
|
||||
i += 1
|
||||
elif not a.startswith("--") and not opts["qr"]:
|
||||
opts["qr"] = a
|
||||
i += 1
|
||||
return opts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["qr"]:
|
||||
print(__doc__)
|
||||
return 1
|
||||
|
||||
if opts["debug"]:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
setup_logging("use_ticket")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---- 心跳:决定后续所有请求的 number 头 ----
|
||||
Ping()
|
||||
|
||||
user_id, token = ParseQRCode(opts["qr"])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
preview = GetUserPreview(user_id, token)
|
||||
if preview.isLogin:
|
||||
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||
return 2
|
||||
|
||||
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
# ---- ★ 读当前持有的功能券 ----
|
||||
all_charges, held = read_charges(user_id, "消耗前")
|
||||
|
||||
if opts["list"]:
|
||||
log_info("--list:只列票券,不做任何写操作")
|
||||
return 0
|
||||
|
||||
if not held:
|
||||
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
|
||||
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
|
||||
return 4
|
||||
|
||||
# ---- 选定目标票 ----
|
||||
target = None
|
||||
if opts["ticket_id"] is not None:
|
||||
for c in held:
|
||||
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
|
||||
target = c
|
||||
break
|
||||
if target is None:
|
||||
log_error("指定的 chargeId=" + str(opts["ticket_id"])
|
||||
+ " 当前并不持有;持有的有:"
|
||||
+ ", ".join(str(c.get("chargeId")) for c in held))
|
||||
return 4
|
||||
else:
|
||||
target = held[0]
|
||||
|
||||
ticket_id = int(target.get("chargeId"))
|
||||
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
|
||||
play_credit = int(PLAY_CREDIT) + credit_num
|
||||
|
||||
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
|
||||
+ " stock=" + str(target.get("stock"))
|
||||
+ " creditNum=" + str(credit_num)
|
||||
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
|
||||
+ "=" + str(play_credit))
|
||||
|
||||
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
|
||||
new_stock = int(target.get("stock", 0) or 0) - 1
|
||||
if new_stock < 0:
|
||||
new_stock = 0
|
||||
new_list = []
|
||||
for c in all_charges:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
item = dict(c)
|
||||
if int(item.get("chargeId", -1)) == ticket_id:
|
||||
item["stock"] = new_stock
|
||||
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
|
||||
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
|
||||
new_list.append(item)
|
||||
|
||||
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
|
||||
snapshot = None
|
||||
if not opts["skip_entry"]:
|
||||
snapshot = GetNeedUserData(user_id, token=token,
|
||||
place_id=PLACE_ID, region_id=REGION_ID)
|
||||
_sum = summarize_snapshot(snapshot)
|
||||
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
|
||||
+ str(len(snapshot.get("_failed") or [])) + " 个")
|
||||
|
||||
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
|
||||
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
|
||||
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
|
||||
# / userData.lastPlayCredit
|
||||
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
|
||||
play = PlayData(
|
||||
music_id=opts["music_id"],
|
||||
music_level=opts["level"],
|
||||
use_ticket_id=ticket_id,
|
||||
play_credit=play_credit,
|
||||
)
|
||||
UpsertUserAll(
|
||||
user_id, login.loginId,
|
||||
login_date_time=login.loginDateTime,
|
||||
auth_date_time=login.dateTime,
|
||||
token=token,
|
||||
snapshot=snapshot,
|
||||
play=play,
|
||||
extra_data={"userChargeList": new_list},
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
verify_landing=bool(opts["send"]),
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
|
||||
# ---- ★ 复查:票券 stock 应该减 1 ----
|
||||
_, held_after = read_charges(user_id, "消耗后")
|
||||
if not opts["send"]:
|
||||
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
|
||||
return 0
|
||||
|
||||
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
|
||||
if still:
|
||||
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
|
||||
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
|
||||
return 6
|
||||
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
|
||||
return 0
|
||||
|
||||
except ConfirmRequiredError:
|
||||
return 5
|
||||
except Exception as exc:
|
||||
log_error(type(exc).__name__ + ": " + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
if session["logged_in"]:
|
||||
try:
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
except Exception as exc:
|
||||
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||
teardown_logging()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,496 @@
|
||||
# maipulse 功能补齐计划(按截图那套移动端来)
|
||||
|
||||
目标:把现在这个网页工具,做成你截的那种移动端 app 的样子 ——
|
||||
底部五个 tab(主页 / 票据 / B50 / 风险 / 设置),每个功能一张卡片,带 60 秒冷却条。
|
||||
|
||||
原则:**已经跑通的后端一个都不重写**,只补缺的 + 重排前端。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点(已有,直接用)
|
||||
|
||||
| 功能 | 后端 | 前端 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 扫码登录 / 续玩 / 退出 | `core/session.py` + `/api/qr` `/api/login` `/api/logout` | 卡片 1 | 能用 |
|
||||
| 2/3/5 倍功能票 | `core/charge.py` + `/api/charge` `/api/ticket` | 卡片 2 | 能用 |
|
||||
| 解小黑屋 | `core/repair.py` + `/api/status` `/api/unstick` | 卡片 7 | 能用(未在真卡住号上复测) |
|
||||
| 歌曲解锁(全量 / 选中) | `core/unlock.py` + `/api/unlock` | 卡片 6 | 能用 |
|
||||
| 收藏品 / 礼物 | `core/items.py` + `/api/items` `/api/newitems` `/api/grant` | 卡片 4、5 | 能用 |
|
||||
| B50 + 导出图片 | `core/b50.py` + `/api/b50` `/api/cover` | 卡片 8 | 能用,导出实测 2240×1608 |
|
||||
| 店铺切换 | `core/shop.py` + `/api/shops` `/api/shop` | 卡片 3 附近 | 能用 |
|
||||
|
||||
这些只做两件事:**搬进新 UI**、**按截图补交互细节**(下拉框、列表、勾选、冷却条)。
|
||||
|
||||
---
|
||||
|
||||
## 二、要补的功能(截图里有,现在没有)
|
||||
|
||||
### 1. 主页(玩家信息全表)
|
||||
数据源:`api.GetNeedUserData(uid, token)` 一次拿全,不需要多发请求。
|
||||
|
||||
要显示的区块(对齐你的截图):
|
||||
- 头部:头像(iconId 拼图)、昵称、Rating、历史最高
|
||||
- Rating 详情:旧谱 Rating / 新谱 Rating / 合计 / 历史最高
|
||||
- 入坑信息:入坑日期、首次游戏、初始 Rom、初始数据版本
|
||||
- 游戏信息:最后游戏、最后游玩、最后登录、Rom 版本、数据版本、最后地区(placeId → 地区名查 `shop.py` 的店铺表)
|
||||
- 游玩统计:总游玩次数、当期游玩、总达成率、总 DX 分、总同步数、Awake
|
||||
- 状态:联网会员、继承、封禁状态、每日奖励
|
||||
- 更多详情:用户 ID(打码)、当前 P、累计 P、图标 ID、名牌 ID、姓名框 ID、背景框 ID、称号 ID、奖杯 ID、伙伴 ID、段位、阶级、段级
|
||||
|
||||
新接口:`GET /api/profile` → 直接吐上面这些字段(前端不再自己拼)。
|
||||
字段名要先实测确认(`userData[0]` 里到底叫 `point` 还是 `userPoint`、`classValue` 之类),拉一次快照对字段。
|
||||
|
||||
### 2. 旅行伙伴
|
||||
数据源:`userCharacterList`(不是 `userItemList`,也不是搭档 `Partner=10`)。
|
||||
|
||||
三个区块:
|
||||
- 发放新伙伴:填 ID → 加到列表 → 一次提交(写成 `userCharacterList` 追加行)
|
||||
- 编组出战槽位:槽 0(队长)~槽 4,每槽选一个已拥有角色 + 可选等级;「把槽 0 复制到其他槽」
|
||||
- 已拥有伙伴:列 `#id Lv n` 的 chips(截图里那 48 个)
|
||||
|
||||
新接口:`GET /api/characters`(拉现有)、`POST /api/characters`(发放 / 编组)。
|
||||
风险:ID 不在机台表里服务端照样存,但客户端会静默跳过 → 结果里要回报“服务端已存、游戏内可能不显示”。
|
||||
|
||||
### 3. 添加舞里程
|
||||
数据源:`userData[0].point`(余额)/ `totalPoint`(累计)。
|
||||
两种写法:累加(当前 + N)/ 覆盖。整个 int32 区间,填负数就是扣。
|
||||
客户端只在“获得”时把余额钳到 99999,商店扣款不带钳位,所以写更大不会被回写。
|
||||
|
||||
新接口:`POST /api/mileage`,返回改前 / 改后的 point 与 totalPoint。
|
||||
这项最容易出事(写坏就是余额异常),默认 dry-run,预检里明确写“覆盖会直接改余额”。
|
||||
|
||||
### 4. 设置 / 关于
|
||||
- 设置:服务器地址(默认 127.0.0.1:8787)、是否自动退出登录、是否只预检
|
||||
- 关于:版本、数据源说明、免责(自用工具)
|
||||
|
||||
不需要后端。
|
||||
|
||||
---
|
||||
|
||||
## 三、前端改造(重点,工作量最大)
|
||||
|
||||
现在是一个 PC 版两列卡片页,要改成截图那种:
|
||||
|
||||
整体
|
||||
- 顶部返回 + 页面标题
|
||||
- 内容区卡片:`background:#f2f2f6`、圆角 12、内边距 16、无边框
|
||||
- 表单控件:Material 风 outlined 输入框(label 浮在边框上)、下拉箭头、大号圆角主按钮
|
||||
- 底部 tab bar:主页 / 票据 / B50 / 风险 / 设置
|
||||
|
||||
交互
|
||||
- 登录后 60 秒冷却:顶部粉色提示条「登录后需冷却 60 秒,剩余 N 秒」,按钮置灰
|
||||
- 所有写操作跑完自动退出登录(可勾选)
|
||||
- 危险操作统一放在「风险」tab 里,进去先一条红色警告卡
|
||||
|
||||
实现方式:单页 + hash 路由(`#/home` `#/ticket` `#/b50` `#/risk/...` `#/settings`),
|
||||
不引框架,保持现在这个 `html/index.html` 一个文件,方便以后套 WebView。
|
||||
|
||||
---
|
||||
|
||||
## 四、实施顺序
|
||||
|
||||
1. **P1 主页信息**:拉一次快照对字段 → `/api/profile` → 主页 tab
|
||||
2. **P2 B50 搬进新 UI**:海报 + 导出(逻辑不动,换壳)
|
||||
3. **P3 票据 / 解锁 / 收藏品搬进新 UI**:+ 冷却条
|
||||
4. **P4 旅行伙伴**:`/api/characters`
|
||||
5. **P5 舞里程**:`/api/mileage`
|
||||
6. **P6 设置 / 关于**
|
||||
|
||||
P1 必须先做,因为伙伴、里程都要读同一份快照,字段对一遍就都对了。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险与纪律
|
||||
|
||||
- 60 秒登录冷却:所有 `UpsertUserAll` 类写入都在 `session.fresh()` 之后等满 60 秒,界面要显示倒计时,不能点了没反应。
|
||||
- 票的坑:`userChargeList` 里只要有 `stock>0`,之后所有写入都会被服务端判非法整包丢弃。所以**先解歌 / 发道具,最后才买票**。
|
||||
- 每项写操作默认 dry-run 预检(只算不发),勾掉「只预检」才真发。
|
||||
- 不动已经跑通的 `settle / charge / unlock / items / b50`,只加接口和新页面。
|
||||
|
||||
---
|
||||
|
||||
## 六、验收口径
|
||||
|
||||
每个功能都要给出:改前值 → 改后值(回读快照确认)+ 一条日志 + 截图,不能只看 `returnCode=1` 就说成功。
|
||||
|
||||
---
|
||||
|
||||
## 进度记录(2026-10-28 05:00)
|
||||
|
||||
对照 Ichino(D:\DSH\maimai com\Ichino,Flutter 客户端)逐项落地。
|
||||
|
||||
### 已完成
|
||||
|
||||
1. **B50 海报重做(照 Ichino best50_poster.dart 原始坐标)**
|
||||
- 底图 `html/assets/b50/base.png`(1762×1850)+ 绝对坐标叠加,不再用 CSS 拼版;
|
||||
- 卡片 300×100,`left = 65 + 320*(i%5)`,Best35 `top = 350 + 120*i/5`,Best15 `top = 1250 + 120*i/5`;
|
||||
- 评级图 `UI_TTR_Rank_*.png`、FC/AP 与 FS/FDX 图标、DX 徽标、难度色 RA 药丸全部走官方素材;
|
||||
- `ra` 换成 Ichino 整数公式(`_BANDS` + `_SSSP_OFFSET=224`),实测 9166 与游戏内一致;
|
||||
- 导出 html2canvas `scale=1` → 1762×1850,已验证;
|
||||
- 未登录时海报署名用打码 ID,不印完整账号。
|
||||
|
||||
2. **主页资料 `GET /api/profile`**(新 `core/profile.py`)
|
||||
- 一次 `GetNeedUserData` 填满;字段照 Ichino `UserDataBean`:Rating 详情 / 入坑信息 / 游戏信息 / 游玩统计 / 状态 / 更多详情;
|
||||
- 前端 6 个分区 + 空态提示。
|
||||
|
||||
3. **旅行伙伴 `GET|POST /api/characters`**(新 `core/characters.py`)
|
||||
- 读:`userCharacterList` + `userData.charaSlot`(int[5],槽 0 队长);
|
||||
- 写:`userCharacterList` + 逐行 `isNewCharacterList`(已有 '0' / 新增 '1'),可同时改 charaSlot;
|
||||
- 默认 dry-run,写后回读核对。
|
||||
|
||||
4. **舞里程 `GET|POST /api/mileage`**(新 `core/mileage.py`)
|
||||
- 改 `point` / `totalPoint`,累加或覆盖,int32 钳位;
|
||||
- userData 整表覆盖 → 先用 `build_user_all` 组好这一局,再替换那一行,保住 playCount / lastPlayDate 等痕迹。
|
||||
|
||||
5. **UI 改版(浅色 Material + 底部 5 tab)**
|
||||
- 主页 / 票据 / B50 / 风险 / 设置;hash 路由 `#home #ticket #b50 #risk #settings`;
|
||||
- 卡片按 `data-tab` 显隐,`main` 改单列 560 居中;
|
||||
- 风险页顶部红框警告 + 顺序铁律(先解歌发道具,最后买票);
|
||||
- 结果用底部轻提示(ticker)播报,日志收进设置页;
|
||||
- 设置页可搜店铺切机台、退出登录。
|
||||
|
||||
### 待办
|
||||
|
||||
- 玩家记录 / 牌子进度页:需要登录态下 `GetUserMusicDetailAll`(当前未登录返回空明文);
|
||||
- 推分推荐暂不算「平均达成率 / 统计样本」,本地没有水鱼聚合数据,不编造;
|
||||
- 真机验证:登录后走一遍 主页 → 旅行伙伴 → 舞里程 的回读。
|
||||
|
||||
### 追加(本轮)
|
||||
|
||||
6. **60 秒冷却条**:`GET /api/state` 新增 `coolRemain`(登录后剩余秒数,金票 LogoutTimeLimit 同口径),
|
||||
票据页粉色条倒计时提示「现在发包会被静默丢弃」。
|
||||
7. **组包自测**:`tools/selftest_payload.py` 用假 snapshot 走 `build_user_all` 验证——
|
||||
userData 49 个字段整行覆盖后一个不丢,point/totalPoint/charaSlot 改写生效,
|
||||
`isNewCharacterList` 位串与 Ichino `BuildListData` 同口径(已有 '0' / 新增 '1')。已跑通。
|
||||
|
||||
### 追加(第 2 轮)
|
||||
|
||||
8. **字段映射自测**:`tools/selftest_fields.py` 把 GetNeedUserData 换成假快照,
|
||||
验证 profile 的 24 个关键字段逐个取到正确值、userId 打码成 `123****78`、
|
||||
characters 能解析出 2 个角色与 `[101..105]` 槽位。已跑通。
|
||||
9. **B50 看大图**:海报下方加「看大图」,克隆一份 1762 宽的原尺寸海报铺进全屏查看器,
|
||||
手机上横向滑动能看清卡片细节;`?b50=ID&zoom=1` 可直接验证。
|
||||
|
||||
### 追加(第 3 轮)
|
||||
|
||||
10. **选择器(解锁歌曲 / 称号 / 头像 / 名牌 / 边框 / 角色)浅色适配**:
|
||||
标题单行省略、搜索框 `flex:1 1 0;width:auto` 不再把标题挤成两行;13000+ 项列表滚动正常。
|
||||
`?picker=music|title|icon|nameplate|frame|character` 可直接验。
|
||||
11. **结算等待遮罩改浅色**(白底圆角 + 深色字 + 蓝色进度条),不再和浅色主题撞色。
|
||||
12. **自动登出真正接线**:`post()` 里对 6 个写接口(ticket/grant/unlock/characters/mileage/unstick)
|
||||
做统一判断——勾了开关且**不是** dry-run 才登出,默认不勾,避免打断连续操作。
|
||||
13. **B50 空态**:没查过时给一句引导,不是空白。
|
||||
|
||||
### 追加(第 4 轮)
|
||||
|
||||
14. **票种档位修正(对照 Astral TICKET_CREDIT_NUM)**:
|
||||
原实现只有 chargeId 2/3/5,实际付费票是 2/3/4/5/6,`creditNum = N-1`(1/2/3/4/5)。
|
||||
已补 4 倍票(creditNum 3)与 6 倍票(creditNum 5),错误文案同步成「2~6 倍票」,
|
||||
下拉选项把 price 改标 creditNum(容易和「金额」混淆)。
|
||||
Ichino 的 `_ticketNameMap` 只有 2/4/5,是显示映射不全——它实际从 `GetUserChargeApi`
|
||||
拿在售列表,所以以服务器列表为准、本地只做换算。
|
||||
15. **对照 Ichino 核过一遍字段表**:`models/user_data.dart` 的 41 个字段与 `core/profile.py` 一一对应,没有多余也没有缺。
|
||||
|
||||
### 追加(第 5 轮)
|
||||
|
||||
16. **旅行伙伴等级对齐 Ichino**:原来 `levels` 想填多少就发多少,现在钳到 **1~999999**
|
||||
(`minCharacterLevel` / `maxCharacterLevel`,0 会被判非法)。
|
||||
读取时附带 `displayLevel = level % 10000` 与 `rebirth = level // 10000`——
|
||||
客户端把界面等级和转生次数编码在一个 int 里,直接显示原始值会看到「Lv10005」。
|
||||
改等级时沿用原行的 `awakening` / `useCount`,不会把使用次数清零(Ichino 原注释点名过这个坑)。
|
||||
自测:`clamp_level` 边界与 `split_level(10005) == (5, 1)` 通过。
|
||||
|
||||
### 追加(第 6 轮)
|
||||
|
||||
17. **整页验收脚本化**:`?dbg=1` 时挂 `window.onerror` + `unhandledrejection`,
|
||||
把异常写进 `<title>`,再用 headless `--dump-dom` 逐个页签读。
|
||||
结果:5 个页签(home / ticket / b50 / risk / settings)**零 JS 错误**,
|
||||
`scrollWidth` 都不超过视口(risk 页 485/500 是纵向滚动条占位),没有横向溢出。
|
||||
|
||||
### 追加(第 7 轮)
|
||||
|
||||
18. **页签对齐 Ichino 的 6 个**:`home_tab.dart` 是 home / tickets / best50 / risk / settings / **about**,
|
||||
于是把「关于」从设置页拆成独立页签(版本、协议来源 Astral、行为口径 Ichino、数据只在本机、几条规矩)。
|
||||
注意 `.card[data-tab]` 的显示规则是逐页签写的,新增页签必须同步加一条 CSS,否则页面全空白。
|
||||
19. **结算等待改成分步提示**:照 Ichino 的 StepProgressCard,进度条上方按阶段翻字——
|
||||
读取快照 / 组包等满 60 秒 / 发送写包 / 回读核对,不再只显示「已等 N 秒」。
|
||||
|
||||
### 追加(第 8 轮)
|
||||
|
||||
20. **前后端 id 一致性 lint**:把 HTML 的 id 集合与 JS 里 `$('…')` / `setText('…')` 的引用对比,
|
||||
只有 `poster` 是渲染时动态建的(正常),34 个 `k*` 字段全部有对应节点——不会出现登录后一片空白。
|
||||
21. **`/api/characters` 参数加固**:`add` / `levels` / `slots` 的 `int()` 原来在 try 之外,
|
||||
前端传了非数字会变成 Flask 的 500 HTML 页面(前端 `r.json()` 直接抛错)。现在返回 JSON 错误。
|
||||
22. **风险页按操作顺序重排**:警告 → 解锁歌曲 → 发放收藏品 → 发放礼物 → 解锁搭档 →
|
||||
旅行伙伴 → 舞里程 → 解禁小黑屋。理由是先解歌、发道具,最后才碰票相关的东西,
|
||||
跟警告卡里那条铁律一致。
|
||||
|
||||
### 追加(第 9 轮)
|
||||
|
||||
23. **UI 预览模式 `?preview=1`**:照 Ichino `dev/ui_preview_data.dart` 的做法,
|
||||
不发任何请求,直接用一份假数据(Rating 15234 那套)把主页 / 旅行伙伴 / 舞里程撑起来,
|
||||
能看到登录后的真实排版,也方便截图比对。数据里特意放了一条 `level=10005`(1 转 5 级)
|
||||
与一条 `999999`(99 转 9999 级),验证转生拆分显示。
|
||||
24. **登录卡随登录态收起**:拿到资料后主页不再顶着扫码框(登出按钮在设置页),
|
||||
登录态失效时再放出来。
|
||||
|
||||
### 追加(第 10 轮)
|
||||
|
||||
25. **预览模式铺满风险页**:`?preview=1` 时自动把旅行伙伴与舞里程的假数据装上,
|
||||
实测渲染:`共 3 个角色|出战槽 #0 101 · #1 102 …`、
|
||||
`已拥有:#101 Lv5 · #102 Lv5(1 转)· #103 Lv9999(99 转)`——转生拆分显示正确,
|
||||
舞里程区显示 `当前余额 234567|累计 9876543`。P4 / P5 的前端展示至此有据可查。
|
||||
|
||||
### 追加(第 11 轮 · 登录态活体验收 + 外观)
|
||||
|
||||
26. **P1 活体验收通过**:`GET /api/profile` 拉真实数据 —— 昵称 maimai、
|
||||
Rating 9168、历史最高 12337、musicRating 9168、游玩 70 局、累计 P 9630、地区 江苏、icon 302,
|
||||
userId 按约定打码成 `128****21`。
|
||||
★ 客户端显示 9168,B50 本地算 9166,差 2 分正好对应那 3 首「定数未收录」的曲目——
|
||||
不是算错,是缺 ds 无法计入,这也反过来印证了 ra 公式没有偏。
|
||||
27. **P4 / P5 活体验收通过**:`/api/characters` 读到 48 个角色,出战槽
|
||||
`455201/455202/455203/455204/550101`;`/api/mileage` 读到 point 2130 / totalPoint 9630。
|
||||
写路径用 dry-run 打真实快照验过:里程 2130→2230、累计 9630→9730(preview.playCount=70 正确保留);
|
||||
旅行伙伴 update=101、willSend=1、patch 键含
|
||||
`userCharacterList / isNewCharacterList / userMusicDetailList / isNewMusicDetailList / userGamePlaylogList`。
|
||||
(未真发写包:改数值属于账号数据,等用户自己决定。)
|
||||
28. **底部图标换成 Tabler Icons**(MIT,本地 `html/assets/icons/*.svg`,不依赖外网):
|
||||
home / ticket / chart-bar / alert-triangle / settings / info-circle,统一线性风格,
|
||||
用 CSS `mask` 上色,选中态自动跟随 `--accent`。
|
||||
29. **外观设置(设置页新增一张卡)**:上传背景图(canvas 压到最长边 1600、jpeg 0.82 存 localStorage)、
|
||||
清除背景、卡片不透明度 30~100%、背景虚化 0~20px、背景压暗 0~70%、恢复默认。
|
||||
卡片走 `rgba(255,255,255,var(--alpha))` + `backdrop-filter`,有背景时顶栏也跟着半透明。
|
||||
持久化实测:写一套 alpha=.6 的配置后换一次加载,背景与透明度都还在。
|
||||
`?looktest=1` 可复现这个自查,`?bgdemo=1` 只看渐变效果不落盘。
|
||||
|
||||
### 追加(第 12 轮 · 多账号)
|
||||
|
||||
30. **`core/accounts.py`**:扫码登录成功后自动把 `userId + token` 存到 `data/accounts.json`
|
||||
(原子写,tmp → replace)。对外只给「打码 userId + 昵称 + 时间 + ref」,
|
||||
**token 与完整 userId 都不出后端**;切换账号时前端回传的是 `ref`
|
||||
(`sha256(userId)[:12]`),后端按 ref 反查真实行。
|
||||
31. **接口**:`GET /api/accounts` 列表;`POST /api/accounts` 支持
|
||||
`action=switch`(用存的 token 重新 `UserLogin`,切之前先把当前号登出,避免 returnCode=100)/
|
||||
`rename` / `delete`。
|
||||
32. **界面**:设置页新增「账号」卡,一行一个账号(昵称 / 打码 id / 最后使用 / 切换 / 删)。
|
||||
切换时有等待遮罩,成功后会刷新主页资料。
|
||||
打码统一成前 3 + 固定四星 + 后 2(`128****21`),不泄露位数。
|
||||
33. **离线上测通过**:upsert / list(current 标记)/ ref 反查 / rename / touch / remove /
|
||||
未知 ref 返回 None 全部符合预期;界面用一条演示数据渲染过,随后已删除,store 现在为空。
|
||||
34. ⏳ **待验**:用存下来的 token 隔一段时间重新登录是否还有效。
|
||||
要通过这一步才算真正确认「不用再扫码」——需要用户扫一次码来写第一条真实记录。
|
||||
|
||||
### 追加(第 13 轮)
|
||||
|
||||
35. **去掉「关于」页**(用户要求):删掉 section、底部按钮、`TABS` 里的 about 与对应 CSS 规则,
|
||||
`about.svg` 也一并删了;全文已无 about 残留。`go()` 本来就有「不在 TABS 里就回落 home」的兜底,
|
||||
旧的 `#about` 书签不会白屏。底部回到 5 个页签均分。
|
||||
|
||||
### 追加(第 14 轮 · 全曲成绩 + 推分推荐)
|
||||
|
||||
36. **`core/history.py`**:`api.GetUserMusicDetailAll(userId, clientId)` 拉全曲成绩
|
||||
(Astral 已把两层嵌套摊平成「一条 = 一个难度」)。achievement 是 ×10000 整数,
|
||||
除以 10000;定数先查 `data/master/ds_override.json`、再查 music.json;
|
||||
算不出定数的标 `unrated`,不进 ra 统计,另出 `unratedItems` 供手工补。
|
||||
统计含 AP / FC / FDX / 平均达成率 / 最高 ra / 按难度分布。
|
||||
37. **`core/recommend.py`**:推分算法。
|
||||
① 每首已打曲子算 SS+/SSS/SSS+ 三档 ra(复用 b50 的整数公式);
|
||||
② 在 B35 榜上的按「新 ra − 自己当前 ra」算,不在榜上的按「新 ra − 榜尾」算;
|
||||
③ `min_achievement()` 对 ra 二分反查,给出「打到 98.9934% 就能顶掉榜尾」这种具体数;
|
||||
④ 候选按定数中位数 ±0.7 收窄(别推荐打不动的),按涨幅排序,前 35 首累加算整体潜力。
|
||||
二分自测:ds 12.0/13.8/14.5 三档 × 四个目标档位,反查值再算回去全部 `>= 目标 ra`,
|
||||
单调性成立;拿不到目标的返回 None。
|
||||
38. **接口**:`GET /api/history`(默认前 120 条、full=1 全量)、
|
||||
`GET /api/recommend`、`POST /api/ds`(合并写覆盖表,ds<=0 表示删)。
|
||||
未登录一律拦下并给明确提示。
|
||||
39. **界面**:B50 页新增「推分建议」卡 —— 两个按钮(算推分 / 看全曲成绩)。
|
||||
推分列出:曲名 + 难度定数 / 当前达成 / ra / 「打到多少」/ 涨幅,顶部三个数(榜尾 ra、可推曲目、整体潜力)。
|
||||
全曲成绩列出统计、难度分布、按曲名搜索、明细行(达成率 / 评级 / FC·AP / ra)。
|
||||
未收录定数的条目单独一区,可直接填数字保存,存完自动重算。
|
||||
40. **踩到的坑**:`stopWait` 未定义(现网只有 `endWait`)→ 加别名;`ds_override` 内层
|
||||
key 是 int、保存时按 str 处理,导致 `pop("3")` 匹配不上、删除静默失效 → 统一转 str。
|
||||
41. ⏳ **待真实验收**:全曲成绩要登录才能拉。扫一次码后验:
|
||||
`/api/history` 条数是否接近真机 739、`/api/recommend` 是否给出合理建议、
|
||||
补完那几条定数后本地算出的 rating 能不能对上客户端 9168。
|
||||
|
||||
### 追加(第 15 轮 · 推分多样性 + 定数联动 + 对分)
|
||||
|
||||
42. **推分算法返工**(离线压测 400 条假成绩暴露出来的):
|
||||
原来按「打到 SSS+」的 ra 排序,结果同一批定数的曲子涨幅完全相同,前排全是 ds 14.5 的歌、
|
||||
完全没有区分度(top8 定数去重后只剩 1 个值)。
|
||||
改成:主档用 **SSS(100%)**算涨幅(SSS+ 只作参考列),同涨幅按 `effort`(还要提多少达成率)
|
||||
升序,再按定数做稀释——**每个定数最多留 3 首**。改完 top10 里出现 4 个不同定数。
|
||||
`total` / `potential` 仍按稀释前的全量算(否则潜力会被低估:39 首 vs 200 首)。
|
||||
43. **★ 定数覆盖表接进 B50**(这条不修的话功能是断的):原来只有 `history.ds_for` 读
|
||||
`ds_override.json`,`b50.ds_of` 不读 —— 用户补完定数,B50 页面还是 9166,永远对不上。
|
||||
现在 `b50.ds_of(music, level, music_id)` 先查覆盖表;即使 music.json 里根本没这首
|
||||
(国服新曲就是这种情况,music 为 None)也能走覆盖表算出 ra。
|
||||
`history` 的 load/save_override 改为委托 b50,两边共用一份实现。
|
||||
实测:覆盖前 ds=None/ra=0 → 覆盖后 ds=14.6/ra=306,B50 与全曲成绩两边结果完全一致。
|
||||
44. **`GET /api/verify` 对分**:客户端 `playerRating` / 服务端 `GetUserRating.rating` /
|
||||
本地按定数算的合计,三个数并排显示,差值不为 0 时直接告诉用户「少了 N 分,M 条定数未收录」。
|
||||
界面在推分卡里加了「核对分数」按钮。
|
||||
45. **全曲成绩缓存**:`history.fetch` 加 5 分钟 TTL 缓存(拉一次要翻十几页,
|
||||
算推分和看成绩连着点时不该重复拉);`/api/history?full=1` 不吃截断。
|
||||
46. **修复**:删除定数覆盖项静默失效(`load_override` 内层 key 是 int,保存路径按 str `pop`,
|
||||
对不上)→ 统一转 str 后删除生效;`stopWait` 未定义 → 加 `endWait` 别名。
|
||||
47. ⏳ **仍待真实数据验收**(要登录):`/api/history` 的真实条数、`/api/recommend` 的真实推荐、
|
||||
`/api/verify` 三处 rating 是否对平(目标要求对上客户端 9168)。
|
||||
|
||||
### 追加(第 16 轮 · ★ rating 对平了:9166 → 9168)
|
||||
|
||||
48. **真实数据验证(不需要登录,`GetUserRating` 是公开接口)**:
|
||||
`GET /api/b50?userId=<真实号>` → `totalRating=9166`、`b35=9166`、`b15=0`、`missing=3`。
|
||||
客户端显示 9168,差 2 分——正是那 3 条定数缺失的 newRatingList。
|
||||
49. **定位到那 3 条是什么**:b15 里 `11806 Expert 10.3913%`、`11836 Master 0%`、
|
||||
`11835 Master 0%`(还有一条 11847 本地已有定数)。ach=0 的三条贡献 0 分,
|
||||
真正差的就是 11806 —— 几乎没打过(10.39%)。
|
||||
50. **补全方式改成自动**:写了 `tools/fetch_df.py`,把水鱼
|
||||
`https://www.diving-fish.com/api/maimaidxprober/music_data`(1404 首,id 与国服 musicId 同源)
|
||||
缓存到 `data/master/music_df.json`(146.6 KB)。
|
||||
`b50.load_music()` 改成「music.json 为主,缺的 id 用水鱼补」,名字与定数一起补上,
|
||||
不再依赖手工填。
|
||||
51. **★ 验证结果**:清空手填覆盖表后重跑 →
|
||||
`11806 Fraq Expert ds=12.2 ra=2`、`11836 ビビデバ ds=11.8`、`11835 はいよろこんで ds=11.6`、
|
||||
`11847 M@GICAL☆CURE! LOVE ♥ SHOT! ds=11.2`,`missing=0`,
|
||||
**`totalRating=9168` —— 与客户端完全一致**。
|
||||
(12.2 × 103913 × 16 // 1e8 = 2,这 2 分就是差的那 2 分。)
|
||||
52. 手填覆盖表仍保留(优先级高于水鱼缓存),作为缓存也没收的新曲兜底。
|
||||
53. ⏳ 剩下要登录才能验的:`/api/history` 真实条数、`/api/recommend` 真实推荐、
|
||||
`/api/verify` 三处 rating 是否显示「已对平」(本地 9168 = 客户端 9168)。
|
||||
|
||||
### 追加(第 17 轮 · 真实数据跑推分 + 摸清全曲接口)
|
||||
|
||||
54. **水鱼缓存合并生效**:`load_music()` 后曲库 1326 → 1411(补了 85 首),
|
||||
11806/11836/11835/11847 的名字与四个难度定数全部到位。
|
||||
`unlock` / `items` 不依赖 `load_music`,所以这个合并不会影响解锁列表。
|
||||
55. **真实 B50 数据跑推分**:拿这个号真实的 50 条(B35+B15)当输入试跑
|
||||
→ 榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1),候选 12 首,
|
||||
整体潜力 +230(SSS 档)/ +371(SSS+ 档)。
|
||||
推荐前几名:心象蜃気楼 97.2864%(+29)、フォニイ 97.0541%(+29)、ウミユリ海底譚 97.4920%(+28)……
|
||||
9 个不同定数,effort 落在 1.1~3.0 之间(都是「再练一点点就有」的量),结果比假数据合理。
|
||||
56. **★ 摸清 `GetUserMusicApi` 与 `GetUserRating` 的差别**:
|
||||
后者免登录就能查(所以 B50 一直能用),前者**必须挂在真实登录会话上**,
|
||||
否则服务端 HTTP 200 但解密后是空串(Astral 报「返回空明文」)。
|
||||
实测直接调 `api.GetUserMusicDetailAll(<真实 userId>)` 就是这个错。
|
||||
结论:全曲成绩一定得先扫码登录,没有免登录的路子。
|
||||
57. `/api/history` 与 `/api/recommend` 的错误改成一句能照着做的提示
|
||||
(「全曲成绩要登录才能拉:GetUserMusicApi 不接受免登录查询」),不再把 Astral 那一屏甩给用户。
|
||||
58. `recommend.suggest` 的字段读取改成 `.get`(B50 的条目没有 `playCount`,
|
||||
用它当输入时会 KeyError;真实来源 history/board 字段并不完全一致)。
|
||||
59. ⏳ 最后一步:全曲成绩的真实条数只能等扫码验证。
|
||||
|
||||
### 追加(第 18 轮 · 免登录推分 + 真实结果验收)
|
||||
|
||||
60. **`/api/recommend` 支持降级**:全曲成绩要登录,但 B50 那 50 条本身就是「我打过的曲子」,
|
||||
拿来当候选池照样能算推分(池子从几百条缩到 50 条,结论依然成立)。
|
||||
未登录时返回 `degraded=true` 并在界面标黄说明;登录后自动走全量成绩。
|
||||
这样**不用扫码就能验收推分功能**,也是给没登录用户的第一屏体验。
|
||||
61. **★ 真实数据验收(这个号,未登录)**:`GET /api/recommend?userId=…`
|
||||
→ `degraded=true`、候选 12 首、潜力 +230(SS+/SSS/SSS+ 三档 137 / 230 / 371)。
|
||||
推荐结果:心象蜃気楼 Master 13.4(97.2864% → 97.3881%,+29)、
|
||||
フォニイ Re:Master 13.3(97.0541% → 97.3655%,+29)、
|
||||
ウミユリ海底譚 Master 13.4(97.4920% → 97.7612%,+28)、
|
||||
失敗作少女 Master 13.1(+27)、命に嫌われている Master 12.8(+22)。
|
||||
62. **`?uid=` 直达**:URL 带 uid 时自动填输入框并查 B50 + 推分,方便看别人的分,也方便我自己验收。
|
||||
63. **界面重排**:海报下面那块原来的「推分建议」其实是**曲库推荐**(从没打过的曲子里挑),
|
||||
和新加的「按你的成绩算」重复,改成:
|
||||
① 曲库推荐默认只铺 6 张卡(3 行)+「展开全部 24 首」,原来 16 张卡占了两千多像素;
|
||||
② 标题分开成「曲库推荐(还没打的)」和「推分建议(按你的成绩)」。
|
||||
64. ⏳ 全曲成绩的真机拉取仍未验证(`GetUserMusicApi` 必须真实登录会话)。
|
||||
|
||||
### 追加(第 19 轮 · 难度筛选 + 一次白屏事故)
|
||||
|
||||
65. **全曲成绩支持按难度筛**:`/api/history?level=3,4`(0=Bsc 1=Adv 2=Exp 3=Mas 4=ReM),
|
||||
后端先按难度过滤再截断(前端过滤只能滤已截断的 120 条,不准),返回 `shownTotal` 与 `levelFilter`。
|
||||
界面在统计下面加了一排 全部 / EXP / MAS / ReM。
|
||||
66. **★ 白屏事故**:给 `.lvtabs` 加样式时,anchor 找的是 `/* ---------- 推分建议 / 全曲成绩 ---------- */`,
|
||||
但那行注释在 **JS 区**(`go()` 函数后面),不是 `<style>` 里 —— CSS 规则被塞进 JavaScript,
|
||||
整页脚本解析失败,只省下 header 和 tabbar。
|
||||
教训:这个文件样式表与脚本都在同一个 html 里,插 CSS 必须定位到 `</style>` 之前,
|
||||
不能靠注释文字找位置。已修复并把规则插到 `</style>` 前。
|
||||
67. 难度 chips 的状态要在 `renderHistory()` 渲染之后同步(chips 是 innerHTML 生成出来的,
|
||||
渲染前 querySelector 拿不到),否则「全部」不会高亮。
|
||||
68. 新曲封面:`/api/cover/11806`、`11836`、`11835`、`11633` 全部 200(96~340KB),
|
||||
海报里新曲不会开天窗。
|
||||
|
||||
### 追加(第 20 轮 · 功能票精简 + 抓到摊平丢 musicId 的真机 bug)
|
||||
|
||||
69. **功能票去掉 4 倍 / 6 倍**:国服机台不再售这两档,留着只是误点。
|
||||
`charge.TICKETS` 只剩 2/3/5(creditNum 1/2/4),`buy()` 对 4、6 直接拒并说明原因,
|
||||
前端 `<select id="chargeId">` 同步只留三项。
|
||||
70. **★ 静态审查抓到真机必崩的 bug**:`api.GetUserMusicDetailAll` 摊平时会丢 `musicId`。
|
||||
服务端返回的是 `{"userMusicList": [{"musicId": N, "userMusicDetailList": [...]}]}` ——
|
||||
musicId 在**组**这一层,detail 里没有;Astral 只做 `flat.extend(group["userMusicDetailList"])`,
|
||||
没有把父层 musicId 带下来。照原样用,全曲成绩会拿到一堆没有曲号的条目 →
|
||||
定数一律 None → 整页变成「未收录」、ra 全 0、推分也全废。
|
||||
这是只有真机才暴露的问题,我这边没有登录拉不到真实数据,是靠读 Astral 源码发现的。
|
||||
71. **改法**:`history._fetch_items()` 自己调 `get_all_pages` 并摊平,
|
||||
每条 detail 补上父层 `musicId`(detail 自带则尊重它);拿不到 `get_all_pages`
|
||||
时才退回官方封装。
|
||||
72. **Mock 验证**:造了两组 3 条 detail(全部不带 musicId),摊平后
|
||||
`musicId 834/834/11806` 都在,定数 14.9 / 15.0 / 12.2 全部算得出,
|
||||
ra 312 / 291 / 2,`unrated=0`。没这个修复,这三条会是 `musicId=0`、名字空、定数空。
|
||||
|
||||
### 追加(第 21 轮 · ★ 更正上一轮的错误结论)
|
||||
|
||||
73. **更正第 70~72 条**:上一轮说「Astral 摊平时丢 musicId、真机必崩」——**判断错了**。
|
||||
证据在 `Astral/selftest.py:548-555` 的 fixture:
|
||||
```python
|
||||
"userMusicDetailList": {"userMusicList": [
|
||||
{"userMusicDetailList": [
|
||||
{"musicId": 143, "level": 4, "playCount": 7, "achievement": 0,
|
||||
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0, ...}]}]}
|
||||
```
|
||||
外层 group 里**没有** musicId,是**内层 detail 自带** `musicId`。
|
||||
`_dispense.py:30` 的 `{(x["musicId"], x["level"]) for x in ...}` 也印证了这一点
|
||||
(它取的就是摊平后的列表)。所以 `history.fetch` 原来的 `it.get("musicId")` 本来就是对的。
|
||||
74. `_fetch_items()` 仍然保留,但定位改成**加固**而不是修 bug:对四种形状都成立 ——
|
||||
detail 带 musicId(真实形状)原样保留;group 带而 detail 不带则补上;
|
||||
两边都带以 group 为准;两边都没有则记 0。实测:
|
||||
A(143,4)/(143,3)、B(834,3)、C(999,3)、D(0,3)。
|
||||
75. **顺便核对了字段名**:`history._row` 用到的 `achievement / level / playCount /
|
||||
comboStatus / syncStatus` 与 selftest fixture 里的 detail 字段**完全一致**,
|
||||
都是 ×10000 的整数达成率,没有拼写或口径问题。
|
||||
76. 结论:全曲成绩这条链路在代码层面没有已知缺陷,剩下的只有真机跑一次。
|
||||
|
||||
### 追加(第 22 轮 · ★★ 真机验收通过,目标达成)
|
||||
|
||||
**用户在机台扫码登录了**(`/api/state` → `logged=true`,账号 maimai / 128****21),
|
||||
全曲成绩这条链路第一次在真号上跑通。
|
||||
|
||||
77. **全曲成绩(真机)**:`GET /api/history` → **207 条**(每曲每难度一条),涉及 **180 首曲子**
|
||||
- 已算 ra **207 条,未收录 0 条**,总游玩次数 255
|
||||
- AP 19 / FC 33 / FDX 0 / 平均达成率 86.2609% / 最高 ra 292
|
||||
- 难度分布:Basic 26 · Advanced 8 · Expert 47 · Master 111 · Re:Master 12 · 宴 3
|
||||
78. **★ 真机抓到两个只有上机才会暴露的问题**:
|
||||
1. **宴/協/息谱算不出定数**:`111355 [協]ラグトレイン`、`100998 [宴]Oshama Scramble!`、
|
||||
`111597 [息]ノンブレス・オブリージュ` 三条的 `level = 10`,
|
||||
—— 服务端用越界值标记特殊谱面,定数存在 `notes[0]`,原来 `notes[level]` 直接越界返回 None。
|
||||
修:`ds_of()` 里 `level >= 5 且越界` 时改用 `notes[0]`;
|
||||
另加 `level_name()`,这几条的难度名显示「宴」而不是「?」。
|
||||
修完 unrated 3 → 0,三条 ra 分别 292 / 175 / 155
|
||||
(協ラグトレイン 175.3337% 是全场最高分,被 clamp 到 100.5%)。
|
||||
2. **`syncStatus = 5` 被映射成 "sync"**:真机 207 条里 **152 条**是 5,
|
||||
显示出来满屏「sync」纯属误导(EasyMai 表里有这个值,但国服没拿它当 FS/FDX 标记)。
|
||||
修:`_SYNC` 去掉 5,只保留 0~4(''/fs/fsp/fdx/fdxp)。
|
||||
79. **★ 自检路由 `/api/selftest`**(登录后一键跑,用户自己也能用):5/5 全过
|
||||
- 全曲成绩条数:207 条 / 180 首
|
||||
- 定数未收录:全部收录
|
||||
- 达成率非零:平均 86.2609%,最高 ra 292
|
||||
- **B35+B15 合计:本地 9168 / 客户端 9168** ✓
|
||||
- 全曲最高分 vs B50 第一:OMAKENO Stroke(Master 13.8)ra 290 / B50 ra 290 ✓
|
||||
- 这条检查第一版没过:宴谱 協ラグトレイン ra 292 比 B50 第一名还高。
|
||||
不是 bug —— 宴谱不参与 B35 排名,比较时排除 level>=5 即可。
|
||||
80. **★ 全曲模式的推分(真机)**:`/api/recommend` → `degraded=false`,候选池 207 条
|
||||
- 可推 **58 首**,榜尾 ra 254(最終鬼畜妹フランドール・S Master 12.1)
|
||||
- 三档潜力:SS+ **+830** / SSS **+1106** / SSS+ **+1526**
|
||||
- 前几名:INTERNET OVERDOSE 94.2194% → +37、神々が恋した幻想郷 92.5207% → +37、
|
||||
ドクハク 96.4595% → +35、ここからはじまるプロローグ。→ +35
|
||||
- 全部 `onBoard=false`(要顶掉榜尾那类),和降级模式(全是榜内)形成对比
|
||||
81. **登录态持久化验证**:重启服务会清空会话(`logged=false`),
|
||||
但 `POST /api/accounts {action:"switch"}` 用保存的 token 能直接恢复 ——
|
||||
实测恢复后 `isLogin=false / banState=0 / loginDateTime=1793198451`,
|
||||
再拉全曲成绩照常成功。也就是说以后重启不用重新扫码。
|
||||
82. **目标五项全部达成**:① 全曲成绩真机拉通 ✓ ② 定数缺漏(含水鱼补全 + 手填覆盖 + 宴谱兜底)✓
|
||||
③ Ichino 整数 ra 公式 + 推分 ✓ ⑤ **9168 = 9168** ✓(④ 移动端按用户要求未做)
|
||||
@@ -0,0 +1,182 @@
|
||||
# maipulse 功能扩展计划(对齐 Ichino)
|
||||
|
||||
参考实现:`D:\DSH\maimai com\Ichino`(Flutter,eaquira 系列的 Dart 移植)
|
||||
本文件只是计划,**不改动现有任何文件**;确认后再按阶段动手。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状盘点
|
||||
|
||||
已做通(实机验证过):
|
||||
|
||||
| 功能 | 写入字段 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| 发功能票 | UpsertUserChargelog | 通过(5 倍票 stock=1) |
|
||||
| 收集品 / 礼物 | userItemList + isNewItemList | 通过(称号/伙伴/名牌/边框) |
|
||||
| 解锁歌曲 | userItemList kind 5/6/7 | 通过(709 首一包全落地) |
|
||||
| 解小黑屋 | UserLogout | 已改金票流程,待实测 |
|
||||
|
||||
Ichino 有、我们没有的:**B50 查分、修改 Rating、舞里程、一键跑图、万花筒**。
|
||||
|
||||
---
|
||||
|
||||
## 二、要加的五个功能
|
||||
|
||||
### 1. B50 查分(只读,零风险)—— 按 EasyMai 规划
|
||||
|
||||
参考:`https://github.com/Lista233/EasyMai`(Vue/Uni-app,202★)
|
||||
关键文件:`pages/maib50/maib50.vue`、`utils/b50adapter.js`、`utils/ratingUtils.js`
|
||||
|
||||
#### 数据源对比(我们比它更直接)
|
||||
|
||||
EasyMai 的路子是绕的:自建后端 `getUserMusic` + 水鱼 `update_records` 导入,
|
||||
再从水鱼拿回算好的 rating。我们不需要这一层:
|
||||
|
||||
| | EasyMai | 我们 |
|
||||
| --- | --- | --- |
|
||||
| 成绩 | 自建后端 + 水鱼 | `api.GetUserRating(user_id, client_id)` 直取 |
|
||||
| 定数 | 本地 `musicData`(水鱼曲库) | 本地 `data/master/music.json` 的 `notes[]` |
|
||||
| 鉴权 | jwt-token + import-token | 只要 userId + 机台 clientId,**不用登录** |
|
||||
| 写操作 | 往水鱼导入成绩 | 无,纯只读 |
|
||||
|
||||
#### 已验证的接口形状(2026-10-28 实测,账号 <REDACTED>)
|
||||
|
||||
GetUserRating -> userRating:
|
||||
rating : 0(服务端不填,别指望它)
|
||||
ratingList : 35 条 {musicId, level, romVersion, achievement}
|
||||
newRatingList : ≤15 条 同上
|
||||
nextRatingList / nextNewRatingList / udemae
|
||||
|
||||
注意 **没有 ra 字段**,必须自己算。定数从
|
||||
`data/master/music.json` 的 `notes[level]` 拼:`level + levelDecimal/10`
|
||||
(例:11633 Master → level 13, levelDecimal 8 → 13.8)。
|
||||
|
||||
#### ra 公式(水鱼标准表,24 档)
|
||||
|
||||
achievement >= 100.5% -> 22.4 >= 95% -> 15.2 >= 82% -> 2.4
|
||||
>= 100% -> 21.6 >= 94% -> 13.6 >= 80% -> 1.6
|
||||
>= 99.5% -> 21.1 >= 93% -> 12.0 >= 78% -> 1.2
|
||||
>= 99% -> 20.8 >= 92% -> 10.4 >= 76% -> 0.8
|
||||
>= 98.5% -> 20.3 >= 91% -> 8.8 >= 74% -> 0.4
|
||||
>= 98% -> 20.0 >= 90% -> 8.0 >= 72% -> 0.2
|
||||
>= 97% -> 17.6 >= 86% -> 4.8 >= 70% -> 0.1
|
||||
>= 96% -> 16.8 >= 84% -> 3.2 其余 -> 0
|
||||
|
||||
ra = floor(ds × coef)
|
||||
|
||||
#### 实测校验(同一账号)
|
||||
|
||||
B35 求和 = 9075,服务端总 rating = 9168,差 93
|
||||
差的来源:newRatingList 4 条里 3 条在国服 music.json 查不到定数(未实装的新曲)
|
||||
|
||||
所以「定数缺失」必须当成正常情况处理,不能当错误。
|
||||
|
||||
#### 展示(照 EasyMai 的结构)
|
||||
|
||||
- 顶部:总 Rating = B35 + B15,配色按 `getCurrentRatingClass()`
|
||||
(≥15000 rainbow / ≥14500 bright-gold / ≥14000 gold / ≥13000 blue / ≥12000 copper)
|
||||
- 两块列表:B35(旧曲 35 条)、B15(新曲最多 15 条),每行
|
||||
曲名 · 难度(Basic/Adv/Exp/Master/Re:Master)· 定数 · 达成率 · ra · FC/FS
|
||||
- FC/FS 适配(`b50adapter.js`):
|
||||
`comboStatus 0/1/2/3/4 -> ''/fc/fcp/ap/app`,
|
||||
`syncStatus 0/1/2/3/4/5 -> ''/fs/fsp/fdx/fdxp/sync`
|
||||
- 定数缺失的行标 `—`,不进合计,并在底部注明"n 首定数缺失"
|
||||
|
||||
#### 第一版不做
|
||||
|
||||
- 封面图(要拉 CDN 并转 base64)
|
||||
- 海报导出、历史快照、分享(EasyMai 本地存 20 条 + 分享图)
|
||||
|
||||
#### 改动面
|
||||
|
||||
core/b50.py 取数 + 算 ra + 组装(约 120 行)
|
||||
app.py + GET /api/b50?userId=
|
||||
index.html + 一张「B50 查分」卡(总分 + 两个列表)
|
||||
|
||||
#### 验收
|
||||
|
||||
- 与游戏内 B50 前三名的 ra 逐个对得上
|
||||
- 总分误差 = 定数缺失那几首的 ra(应能解释清楚,不是bug)
|
||||
|
||||
### 2. 修改 Rating(写,高风险)
|
||||
|
||||
- 字段:`userData[0].playerRating` + `userRatingList[0].rating`,**两处必须同时改且一致**
|
||||
- 范围 0 ~ 99999(Ichino `clampValue`)
|
||||
- 已知后果:下次真实游玩会被服务端按成绩重算覆盖;`ratingList` 里每首的 rating 不动
|
||||
- 新增:`core/rating.py`
|
||||
|
||||
### 3. 舞里程(写,中风险)
|
||||
|
||||
- 字段:`userData[0].point`(int32 余额),可选带 `totalPoint`(累计)
|
||||
- 两种模式:设为 X / 当前值 + X;负数即扣款
|
||||
- 客户端的钳位只发生在「获得」路径(`UserDetail.AddMile` 钳到 99999),商店扣款不钳,
|
||||
所以传大值不会被客户端回写覆盖
|
||||
- 新增:`core/point.py`
|
||||
|
||||
### 4. 一键跑图(写,中风险)
|
||||
|
||||
- 字段:`userMapList` + `isNewMapList`,主键 mapId,每行
|
||||
`{mapId, distance, isLock, isClear, isComplete, unlockFlag}`
|
||||
- 关键:`isClear` / `isComplete` 在客户端是**从 distance 派生**的
|
||||
(`MapMaster.CreateUserDataMapList`),单发 flag 下次进区域选择就被冲掉,
|
||||
所以必须把 `distance` 直接推到 `UserMapData.MaxDistance = 999999999`
|
||||
- `unlockFlag` 语义是反的:`IsFinishedOpening ? 0 : 1`,取 0 才是「已开启」
|
||||
- 需要先 `api.GetUserMapAll()` 拿现有行合并(保留已有进度,别清零)
|
||||
- 新增:`core/maprun.py`
|
||||
|
||||
### 5. 万花筒专区(写,高风险)
|
||||
|
||||
- 字段:`userKaleidxScopeList` + `isNewKaleidxScopeList`
|
||||
- 每行是**整行替换**(15 字段),`isGateFound` / `isKeyFound` / `isClear` 同在一条
|
||||
- 陷阱:不先回读原行合并成绩 / 日期 / playCount,会把已有记录清零
|
||||
- 需先 `api.GetUserKaleidxScope()`
|
||||
- 新增:`core/kaleidx.py`
|
||||
|
||||
---
|
||||
|
||||
## 三、所有写入项的统一前置(这次踩坑换来的)
|
||||
|
||||
1. **先查票**:`GetUserCharge` 里任何 `stock > 0` 的功能票都会让 UpsertUserAll 整包被判非法,
|
||||
先拦下来提示「号上有票,先清票/等过期」;
|
||||
2. **等满 60 秒**:登录后不足一分钟的结算包会被静默丢弃 → 复用 `settle.ensure_play_window`;
|
||||
3. **一条占位 playlog + 一条占位 detail**(大包模式),跟发收藏品一致;
|
||||
4. **一包一局**:每次 `session.fresh` 换局,一局只收一次 UpsertUserAll;
|
||||
5. 每项写完都回读校验(`GetUserData` / `GetUserMap` / `GetUserRating`),不回读不算成功。
|
||||
|
||||
---
|
||||
|
||||
## 四、实施顺序
|
||||
|
||||
| 阶段 | 内容 | 风险 | 验收方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| A | B50 查分 | 无(只读) | 页面显示总分与 50 首,和游戏内对得上 |
|
||||
| B | 舞里程、一键跑图 | 中(可回退) | 改完回读数值;游戏内看里程/区域状态 |
|
||||
| C | 修改 Rating、万花筒 | 高 | 改完回读;rating 以游戏内显示为准 |
|
||||
|
||||
---
|
||||
|
||||
## 五、改动面(确认后才动)
|
||||
|
||||
新增文件(互不影响):
|
||||
|
||||
core/b50.py core/rating.py core/point.py core/maprun.py core/kaleidx.py
|
||||
|
||||
追加式改动(只在文件末尾加,不动已有逻辑):
|
||||
|
||||
app.py +6 条路由(/api/b50, /api/rating, /api/point, /api/map, /api/kaleidx, /api/kaleidx/list)
|
||||
html/index.html +3 张卡(B50 查分 / 数值修改 / 拓展功能)
|
||||
|
||||
复用不修改:
|
||||
|
||||
core/settle.py(60 秒窗口、占位 playlog)
|
||||
core/session.py(登录/换局/登出)
|
||||
core/items.py(GetUserItem 封装,用来回读)
|
||||
core/charge.py(查票的闸门)
|
||||
|
||||
---
|
||||
|
||||
## 六、不做的事
|
||||
|
||||
- 不移植 Ichino 的 B50 PNG 海报(要引图表依赖,先上表格)
|
||||
- 不动现有的六项功能与其界面
|
||||
- 不引入 Flutter 那套 UI,继续用当前单页
|
||||
@@ -0,0 +1,174 @@
|
||||
# maipulse 发包工具 — 实施计划
|
||||
|
||||
> 目标:舞萌DX 国服(SDGB)本地发包工具,六项功能,GUI 操作,协议复用 Astral。
|
||||
> 计划版本:v1(2026-10-21),发票口径已确认 = 2/3/5 倍功能票(charge 体系)。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与范围
|
||||
|
||||
| 功能 | 协议落点 | 状态 |
|
||||
|---|---|---|
|
||||
| 发票(2/3/5 倍功能票) | `UpsertUserChargelogApi`(chargeId 2/3/5) | 复用 Astral `buy_ticket.py` 链路 |
|
||||
| 解禁小黑屋 | `UserLogoutApi`(type 5/4)+ `GetUserPreview.banState` 检测 | 待实现(会话解卡口径) |
|
||||
| 解锁伙伴 | `userItemList` itemKind=10(Partner) | 待实现 |
|
||||
| 发礼物 | `userItemList` itemKind=4(Present) | 待实现 |
|
||||
| 发收藏品 | `userItemList` itemKind=1/2/3/9/11(Plate/Title/Icon/Character/Frame) | 待实现 |
|
||||
| 解锁歌曲(一键 / 选中) | `userItemList` itemKind=5/6/7 | 内核已有,需改成真全量+分批 |
|
||||
|
||||
非目标:不做修改客户端、不做注入、不做成绩伪造以外的机台侧改动。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状(已核实)
|
||||
|
||||
1. 协议层:`D:\DSH\maimai\Astral`(主分支 mai),`api/` 40+ 接口,`MaimaiNet.py` 负责 AES-256-CBC + zlib + MD5 URL 混淆 + Cookie 会话。
|
||||
2. 环境:Python 3.13 + flask 3.1.0 / requests 2.32.5 / pycryptodome 3.23.0 / openpyxl 3.1.5,全部可用。
|
||||
3. 机台身份:`config.py` 已配 CLIENT_ID `A63E01E3636`、PLACE_ID `2282`、REGION_ID `15` → KEYCHIP_ID `A63E-01E36362282`。
|
||||
4. 反编译源码:`sdgb-unpack\full.cs\Assembly-CSharp.unpack.decompiled.cs`(9.0 MB),`ItemKind` 枚举已确认 15 种。
|
||||
5. 数据表:`maipulse\data\master\` 下 ticket / partner / title / icon / plate / frame / chara / music 八张。
|
||||
6. 店铺表:`SDGB_keychips_20260929.csv.xlsx`,3383 行,字段 client_id / place_id / name / region_id / region_name;**place_id 混十进制与十六进制**(`859` 与 `"082f"` 并存),必须统一解析。
|
||||
7. 现有 GUI:`maipulse\app.py`(Flask,400 行)+ `html\index.html`(六面板 + 选择器),内核有下述 5 个必修缺陷。
|
||||
|
||||
---
|
||||
|
||||
## 三、六项功能的实现定义
|
||||
|
||||
### 1. 发票(确认口径:2/3/5 倍功能票)
|
||||
|
||||
- 接口:`UpsertUserChargelogApi`,走 Astral `api.UpsertUserChargelog()`。
|
||||
- chargeId 与上报 price(= ticket.creditNum,不是金额):
|
||||
- 2 → 2倍チケット,price 1
|
||||
- 3 → 3倍チケット,price 2
|
||||
- 5 → 5倍チケット,price 4
|
||||
- 调用链:`ParseQRCode → GetUserPreview(顶号)→ UserLogin → GetUserCharge(查重)→ 等 15s → UpsertUserChargelog → 再查一次确认 → UserLogout(TestIn)`
|
||||
- 内部已有闸门:chargeId 必须是付费票(ticketKind=1)、`stock > 0` 直接拒(重复购买有封号风险)、同账号同进程只允许一次。
|
||||
- UI:票种选择 2/3/5、当前持有列表(chargeId / stock / validDate)、购买按钮、预检开关。
|
||||
|
||||
### 2. 解禁小黑屋
|
||||
|
||||
- 口径:账号卡在 `isLogin=True`(扫码提示已登录、机台进不去)。
|
||||
- 做法:先 `GetUserPreview` 读状态;能拿到 preview 说明二维码有效,直接 `UserLogin` 后立刻 `UserLogout(type=5 Quit 或 4 TestIn)`,loginDateTime 用本次 login 返回的值。
|
||||
- `banState != 0`(橙/红限制)属于服务端下发字段,客户端写不进 `userData`,只做**检测 + 明示**,不伪造解除。
|
||||
- UI:一键解卡 + 状态展示(isLogin / banState / lastLoginDate)。
|
||||
|
||||
### 3. 解锁伙伴
|
||||
|
||||
- `userItemList` 条目 `{"itemKind":10,"itemId":<partnerId>,"stock":1,"isValid":true}`。
|
||||
- 数据源:`data/master/partner.json`(id / name / lock / norm)。
|
||||
- 可选增强:`userIntimateList`(主键 partnerId,字段 intimateLevel / intimateCountRewarded)——单独开关,默认不动。
|
||||
|
||||
### 4. 发礼物
|
||||
|
||||
- `userItemList` 条目 itemKind=4(Present),id 表待补:现有 `master.json` 无 present 分类,需要从 `sdgb.lemonno.xyz` 或反编译 `PresentData` 表导出。
|
||||
|
||||
### 5. 发收藏品
|
||||
|
||||
- 一个通用发放器按 kind 切表:
|
||||
- 1 Plate(`plate.json`)
|
||||
- 2 Title(`title.json`,724 KB)
|
||||
- 3 Icon(`icon.json`)
|
||||
- 9 Character(`chara.json`)
|
||||
- 11 Frame(`frame.json`)
|
||||
- UI:分类切换 + 搜索 + 多选 + 数量。
|
||||
|
||||
### 6. 解锁歌曲
|
||||
|
||||
- itemKind:5 本体 / 6 Master / 7 Re:Master(8 = Strong,前端标签需改)。
|
||||
- 规则:musicId < 10000 的标准谱 Master/Re 天生解锁,不写 6/7;只写 5。
|
||||
- 一键全量:`music.json` 全表 → 减掉 `GetUserItem(5)` 已有 → 分批(单包 ≤4 首,规避 2 首包被拒,按 4/3/3 切)→ 每包前 fresh session(登出 + 重登)→ 落库回查。
|
||||
- 选中解锁:id 列表直接走 `UpsertUserAll(unlock_music_ids=[...])`。
|
||||
|
||||
---
|
||||
|
||||
## 四、内核必修(审计结论)
|
||||
|
||||
1. **位串长度错**:`_push_items` 发完整 `userItemList`,却只给 `"1"*新增数` 的 `isNewItemList`。`BuildListData` 要求一位一条 → 必须逐条 0(存量)/1(新增),否则 500 或重复插入。
|
||||
2. **全量解锁是假的**:`unlock_music_ids=[-1]` 会被 `build_unlock_items` 直接丢弃(只认 >0 的 id)。必须换成真实全表 + 分批。
|
||||
3. **假成绩风险**:固定塞 `music 11308 / level 3 / achievement 0` 的 playlog,服务端可能不收;改为最小合法 playlog,且不污染账号统计(playCount 增量可控)。
|
||||
4. **每包新会话**:服务端把一次登录当一局,一局只收一次 Upsert → 批量流程强制 `_fresh_session`。
|
||||
5. **kind8 命名**:Survival → Strong(`MusicUnlockStrongList`)。
|
||||
6. **place_id 解析**:xlsx 里 `"082f"` 按十六进制 → 2095,`859` 按十进制;统一转 int 后再拼 KEYCHIP。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构
|
||||
|
||||
```
|
||||
maipulse/
|
||||
app.py Flask 路由(/api/qr /login /logout /charge /grant /unlock /repair /shop /master)
|
||||
core/session.py 会话:ParseQRCode、GetUserPreview、UserLogin、fresh_session、UserLogout
|
||||
core/charge.py 功能票:GetUserCharge 查重 → 15s → UpsertUserChargelog → 回查
|
||||
core/grant.py 通用道具:GetUserItem(kind) → 合并 → 位串 → UpsertUserAll patch
|
||||
core/unlock.py 歌曲:全量分批 / 选中
|
||||
core/repair.py 小黑屋:状态检测 + 解卡登出
|
||||
core/shop.py xlsx 解析 + 店铺搜索 + config 回写 + KEYCHIP 校验
|
||||
data/master/*.json
|
||||
html/index.html 六面板
|
||||
```
|
||||
|
||||
原则:协议层只 import Astral,不复制、不 fork;业务层只做编排与数据合并;前端只发 HTTP。
|
||||
|
||||
---
|
||||
|
||||
## 六、里程碑
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| M0 冒烟 | 跑 `selftest.py`、起 maipulse、dry-run 空包 | 284 项绿 + 页面可开 |
|
||||
| M1 内核 | 位串 / 全量 / playlog / fresh session / 回查 | dry-run 报文节点与位串长度一致 |
|
||||
| M2 功能 | 票 → 伙伴 → 收藏品 → 礼物 → 歌曲 → 小黑屋 | 每项 dry-run + 单条实发 + Get 回查 |
|
||||
| M3 店铺 | xlsx 解析(hex 修正)+ 店铺选择 + config 回写 | KEYCHIP 与表内一致 |
|
||||
| M4 界面 | 六面板 + 状态卡 + 错误详情 + 日志 | 全流程浏览器操作 |
|
||||
| M5 验收 | 逐功能 预检 → 实发 → 游戏内确认 | 验收记录 + 截图 |
|
||||
|
||||
---
|
||||
|
||||
## 七、风险与验证口径
|
||||
|
||||
- 一局一 Upsert、单包 ≤4 首、2 首包被拒 → 分批固定 4/3/3。
|
||||
- 重复插入已有条目 → HTTP 500;所有发放前必须先 Get 差集。
|
||||
- `banState` 只读,不伪造。
|
||||
- 二维码 10 分钟过期,一过期就得回机台重扫。
|
||||
- 所有写操作默认 dry-run,UI 上必须显式勾选「真实发包」。
|
||||
- 每步落库回查(GetUserItem / GetUserCharge),以服务器回读为准,不以本地构造为准。
|
||||
|
||||
---
|
||||
|
||||
## 八、实测结论(2026-10-27)
|
||||
|
||||
### ★ 一局至少 60 秒 —— 之前所有"回码 1 但不落库"的真正原因
|
||||
|
||||
MaiTechUI 金票包 UpsertUserAllApi.poop() 原样:
|
||||
|
||||
long num = Configs.getNowUnixTime() - Configs.NowLoginTime;
|
||||
Thread.Sleep(Configs.LogoutTimeLimit - (int)num * 1000); // LogoutTimeLimit = 60000
|
||||
return base.poop();
|
||||
|
||||
它从不在登录后 60 秒内提交结算。服务端对"登录不到一分钟就交成绩"的包一律回
|
||||
returnCode=1 然后把 userItemList / userMusicDetailList 整块丢掉。
|
||||
|
||||
已验证(账号 <REDACTED>):每次发包前补足 60 秒后,四类道具全部真落地。
|
||||
|
||||
| kind | 名称 | 发送 id | 库存变化 | waited |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 2 | 称号 | 32 | 147 → 148 | 57.7s |
|
||||
| 10 | 伙伴 | 12 | 22 → 23 | 52.6s |
|
||||
| 1 | 名牌 | 2 | 49 → 50 | 57.4s |
|
||||
| 11 | 边框 | 2 | 41 → 42 | 56.8s |
|
||||
|
||||
实现:core/settle.py:ensure_play_window(),登录时在 state 里记 login_at,
|
||||
items.grant / unlock.unlock_selected / unlock.unlock_all 在发 UpsertUserAll 前调用。
|
||||
|
||||
### 本局必须是 dummy 曲 11538 的自洽全 Miss
|
||||
|
||||
除了 playlog,还要把这一局的四处痕迹一起对上(见 settle.py 头注释):
|
||||
userData.lastPlayDate/dateTime、userMusicDetailList + isNewMusicDetailList、
|
||||
userGamePlaylogList(playTrack=1 / playlogId=loginId / playSpecial 随机置换)、
|
||||
userPlaylogList(achievement=0 且有 miss)。
|
||||
|
||||
### 重启必须走 tools/restart.ps1
|
||||
|
||||
直接杀进程会把服务端会话留在 isLogin=True,账号进小黑屋(登录回 100),
|
||||
且 loginId 只存在内存里,事后连 UserLogout 都发不对。
|
||||
/api/shutdown 会先登出再退出。
|
||||
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""maipulse 业务层:会话 / 道具 / 功能票 / 解禁 / 解卡 / 店铺。
|
||||
|
||||
协议实现全部来自同目录的 Astral 工程(api/ 包),这里只做编排。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# ★ 让 core.* 无论从哪个入口 import 都能找到 api 包(Flask、自检脚本、CLI 都一样)
|
||||
_ASTRAL = os.environ.get("ASTRAL_DIR", r"D:\DSH\maimai\Astral")
|
||||
if os.path.isdir(_ASTRAL) and _ASTRAL not in sys.path:
|
||||
sys.path.insert(0, _ASTRAL)
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""多账号:把扫码换来的 userId + token 存本机,之后直接用它重新登录。
|
||||
|
||||
为什么能省掉扫码:二维码只是一次性入口,扫出来的 token 才是账号凭证。
|
||||
`session.login(state, user_id, token)` 每天都用它做 UserLogin——只要机台上
|
||||
没有重新扫码把旧凭证顶掉,同一个 token 可以反复登录。
|
||||
|
||||
★ token 属于敏感信息:
|
||||
· 只写 `data/accounts.json`(本机,不进任何仓库、不上传);
|
||||
· 接口返回给前端的只有打码 userId 与昵称,token 不出这个模块。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_STORE = Path(__file__).resolve().parent.parent / "data" / "accounts.json"
|
||||
|
||||
|
||||
def mask_user_id(user_id) -> str:
|
||||
"""12345678901 -> 123****01(和面板其它地方同一口径)。"""
|
||||
s = str(user_id or "")
|
||||
if not s:
|
||||
return "-"
|
||||
if len(s) <= 6:
|
||||
return s[:1] + "***"
|
||||
return s[:3] + "****" + s[-2:] # 固定 4 星,不泄露位数
|
||||
|
||||
|
||||
def ref_of(user_id) -> str:
|
||||
"""对外引用:userId 的短哈希。
|
||||
|
||||
前端拿到的永远是打码 id 与这个 ref,切换账号时只回传 ref,
|
||||
完整 userId 与 token 都不出后端。
|
||||
"""
|
||||
return hashlib.sha256(str(int(user_id)).encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def get_by_ref(ref):
|
||||
ref = str(ref or "")
|
||||
if not ref:
|
||||
return None
|
||||
for row in _load():
|
||||
if ref_of(row["userId"]) == ref:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _load() -> list:
|
||||
try:
|
||||
raw = json.loads(_STORE.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
rows = raw.get("accounts") if isinstance(raw, dict) else raw
|
||||
out = []
|
||||
for row in (rows or []):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
uid = int(row.get("userId") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
token = str(row.get("token") or "")
|
||||
if uid <= 0 or not token:
|
||||
continue
|
||||
out.append({
|
||||
"userId": uid,
|
||||
"token": token,
|
||||
"name": str(row.get("name") or ""),
|
||||
"savedAt": str(row.get("savedAt") or ""),
|
||||
"lastUsed": str(row.get("lastUsed") or ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _save(rows: list) -> None:
|
||||
_STORE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _STORE.with_suffix(".tmp")
|
||||
payload = {"accounts": rows}
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, _STORE) # 原子替换,写一半断电不会把存档写坏
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def list_accounts(current_user_id=None) -> list:
|
||||
"""给前端看的列表:没有 token,只有打码 id / 昵称 / 时间。"""
|
||||
cur = str(current_user_id or "")
|
||||
rows = _load()
|
||||
rows.sort(key=lambda r: r.get("lastUsed") or "", reverse=True)
|
||||
return [{
|
||||
"ref": ref_of(r["userId"]),
|
||||
"userId": mask_user_id(r["userId"]),
|
||||
"name": r["name"] or "(未命名)",
|
||||
"lastUsed": r["lastUsed"] or r["savedAt"] or "-",
|
||||
"current": str(r["userId"]) == cur,
|
||||
} for r in rows]
|
||||
|
||||
|
||||
def upsert(user_id, token, name: str = "") -> None:
|
||||
"""登录成功后保存 / 刷新这个账号。"""
|
||||
uid = int(user_id)
|
||||
token = str(token or "")
|
||||
if uid <= 0 or not token:
|
||||
return
|
||||
rows = _load()
|
||||
for row in rows:
|
||||
if row["userId"] == uid:
|
||||
row["token"] = token
|
||||
row["lastUsed"] = _now()
|
||||
if name:
|
||||
row["name"] = name
|
||||
break
|
||||
else:
|
||||
rows.append({"userId": uid, "token": token, "name": name or "",
|
||||
"savedAt": _now(), "lastUsed": _now()})
|
||||
_save(rows)
|
||||
|
||||
|
||||
def get(user_id):
|
||||
uid = int(user_id)
|
||||
for row in _load():
|
||||
if row["userId"] == uid:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def touch(user_id) -> None:
|
||||
uid = int(user_id)
|
||||
rows = _load()
|
||||
for row in rows:
|
||||
if row["userId"] == uid:
|
||||
row["lastUsed"] = _now()
|
||||
_save(rows)
|
||||
return
|
||||
|
||||
|
||||
def rename(user_id, name: str) -> bool:
|
||||
uid = int(user_id)
|
||||
rows = _load()
|
||||
for row in rows:
|
||||
if row["userId"] == uid:
|
||||
row["name"] = str(name or "")[:40]
|
||||
_save(rows)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove(user_id) -> bool:
|
||||
uid = int(user_id)
|
||||
rows = _load()
|
||||
left = [r for r in rows if r["userId"] != uid]
|
||||
if len(left) == len(rows):
|
||||
return False
|
||||
_save(left)
|
||||
return True
|
||||
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""B50 查分(只读,不发包)。
|
||||
|
||||
数据链路(比 EasyMai 直接一层):
|
||||
GetUserRating(userId, clientId) -> userRating.ratingList(35) + newRatingList(≤15)
|
||||
定数 -> data/master/music.json 的 notes[level]:level + levelDecimal/10
|
||||
ra -> floor(ds × 系数),系数是水鱼标准 24 档表
|
||||
|
||||
★ 服务端**不返回 ra**(rating 字段恒为 0),必须自己算。
|
||||
★ 国服曲库可能缺新曲定数(实测 newRatingList 4 条里 3 条查不到),
|
||||
这种情况按「未收录」返回,不参与合计,不是错误。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
|
||||
import api
|
||||
|
||||
from . import session
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MUSIC_JSON = os.path.join(APP_DIR, "data", "master", "music.json")
|
||||
#: 手填的定数覆盖表(国服新曲 music.json 里没有,ra 就算不出来)
|
||||
OVERRIDE_JSON = os.path.join(APP_DIR, "data", "master", "ds_override.json")
|
||||
#: 水鱼曲库缓存(tools/fetch_df.py 生成)—— 补 music.json 里还没有的新曲名字与定数
|
||||
DF_JSON = os.path.join(APP_DIR, "data", "master", "music_df.json")
|
||||
|
||||
LEVEL_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
|
||||
|
||||
# ★ ra / 评级:照 Ichino `services/rating_calculator.dart`(移植自 Empurple),
|
||||
# 整数运算,和客户端一致。之前用的 24 档系数表会差 1 分(13.8 / 99.6636 那张
|
||||
# 表给 291,正确的 290),所以改成这个。
|
||||
# 达成率区间上界(原始值 = achievement × 10000)/ RA 系数 / 评级。
|
||||
_BANDS = (
|
||||
(100000, 0, "D"), (200000, 16, "D"), (300000, 32, "D"), (400000, 48, "D"),
|
||||
(500000, 64, "D"), (600000, 80, "C"), (700000, 96, "B"), (750000, 112, "BB"),
|
||||
(799999, 120, "BBB"), (800000, 128, "BBB"), (900000, 136, "A"), (940000, 152, "AA"),
|
||||
(969999, 168, "AAA"), (970000, 176, "AAA"), (980000, 200, "S"), (989999, 203, "Sp"),
|
||||
(990000, 206, "Sp"), (995000, 208, "SS"), (999999, 211, "SSp"), (1000000, 214, "SSp"),
|
||||
(1004999, 216, "SSS"), (1005000, 222, "SSS"),
|
||||
)
|
||||
_MAX_ACH = 1005000
|
||||
_SSSP_OFFSET = 224
|
||||
|
||||
# 照 EasyMai utils/b50adapter.js
|
||||
_COMBO = {0: "", 1: "fc", 2: "fcp", 3: "ap", 4: "app"}
|
||||
# ★ 真机上 syncStatus=5 占多数(实测 207 条里 152 条),EasyMai 表里给的是 "sync",
|
||||
# 但这个值在国服没有被当成 FS/FDX 系标记用,显示出来只会让人困惑 —— 不映射。
|
||||
_SYNC = {0: "", 1: "fs", 2: "fsp", 3: "fdx", 4: "fdxp"}
|
||||
|
||||
_music_cache = None
|
||||
_override_cache = None
|
||||
|
||||
|
||||
def load_override() -> dict:
|
||||
"""{musicId(int): {level(int): ds}} —— 手填的定数覆盖表,改了文件要 reload_override()。"""
|
||||
global _override_cache
|
||||
if _override_cache is None:
|
||||
data = {}
|
||||
if os.path.exists(OVERRIDE_JSON):
|
||||
try:
|
||||
with open(OVERRIDE_JSON, encoding="utf-8") as fp:
|
||||
raw = json.load(fp) or {}
|
||||
for mid, levels in raw.items():
|
||||
try:
|
||||
key = int(mid)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(levels, dict):
|
||||
continue
|
||||
slot = {}
|
||||
for lv, ds in levels.items():
|
||||
try:
|
||||
slot[int(lv)] = float(ds)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if slot:
|
||||
data[key] = slot
|
||||
except (OSError, ValueError):
|
||||
data = {}
|
||||
_override_cache = data
|
||||
return _override_cache
|
||||
|
||||
|
||||
def reload_override() -> dict:
|
||||
global _override_cache
|
||||
_override_cache = None
|
||||
return load_override()
|
||||
|
||||
|
||||
def _df_rows() -> dict:
|
||||
"""水鱼缓存 -> 与 music.json 同形状的曲目行(notes 按 ds 拆成 level/levelDecimal)。"""
|
||||
if not os.path.exists(DF_JSON):
|
||||
return {}
|
||||
try:
|
||||
with open(DF_JSON, encoding="utf-8") as fp:
|
||||
raw = json.load(fp) or {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
out = {}
|
||||
for key, row in raw.items():
|
||||
try:
|
||||
mid = int(key)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
notes = []
|
||||
for ds in (row.get("ds") or []):
|
||||
try:
|
||||
value = round(float(ds), 1)
|
||||
except (TypeError, ValueError):
|
||||
notes.append({"level": 0, "levelDecimal": 0})
|
||||
continue
|
||||
base = int(value)
|
||||
dec = int(round((value - base) * 10))
|
||||
notes.append({"level": base, "levelDecimal": dec})
|
||||
out[mid] = {"id": mid, "name": row.get("name") or "", "notes": notes,
|
||||
"_source": "df"}
|
||||
return out
|
||||
|
||||
|
||||
def load_music() -> dict:
|
||||
"""musicId -> 曲目行(含 notes 定数)。缓存常驻,改文件后重启即可。
|
||||
|
||||
★ 以 music.json 为主;里面没有的 id 用水鱼缓存补上(名字 + 定数)。
|
||||
国服 music.json 会比服务端曲库旧一个版本,新曲不补的话
|
||||
B50 的 newRatingList 会有几条算不出 ra、rating 比客户端少几分。
|
||||
"""
|
||||
global _music_cache
|
||||
if _music_cache is None:
|
||||
with open(MUSIC_JSON, encoding="utf-8") as fp:
|
||||
rows = json.load(fp)
|
||||
db = {int(m.get("id") or 0): m for m in rows if m.get("id")}
|
||||
added = 0
|
||||
for mid, row in _df_rows().items():
|
||||
if mid in db:
|
||||
continue
|
||||
db[mid] = row
|
||||
added += 1
|
||||
_music_cache = db
|
||||
if added:
|
||||
print("[b50] 水鱼缓存补了 %d 首(music.json 里没有)" % added)
|
||||
return _music_cache
|
||||
|
||||
|
||||
def ds_of(music: dict, level: int, music_id=None):
|
||||
"""定数:先查手填覆盖表,再取 notes[level] 的 level + levelDecimal/10。
|
||||
|
||||
★ 覆盖表必须在这里生效 —— B50 和全曲成绩共用这套定数,
|
||||
否则用户补完定数,B50 页面还是老的 9166,两边对不上。
|
||||
"""
|
||||
if music_id:
|
||||
manual = load_override().get(int(music_id), {}).get(int(level))
|
||||
if manual:
|
||||
return round(float(manual), 1)
|
||||
notes = (music or {}).get("notes") or []
|
||||
# ★ 宴 / 協 / 息 谱:服务端 level 用 10 这类越界值标记,定数放在 notes[0]。
|
||||
# 不这么处理这几条永远算不出 ra(真机实测 3 条:協ラグトレイン、宴Oshama、息ノンブレス)
|
||||
if level >= 5 and level >= len(notes) and notes:
|
||||
level = 0
|
||||
if level < 0 or level >= len(notes):
|
||||
return None
|
||||
note = notes[level] or {}
|
||||
base = int(note.get("level") or 0)
|
||||
if base <= 0:
|
||||
return None
|
||||
return round(base + int(note.get("levelDecimal") or 0) / 10.0, 1)
|
||||
|
||||
|
||||
def level_name(level) -> str:
|
||||
"""难度名。宴/協/息谱服务端用 level>=5 标记,界面上统一显示「宴」。"""
|
||||
try:
|
||||
level = int(level)
|
||||
except (TypeError, ValueError):
|
||||
return "?"
|
||||
if 0 <= level < len(LEVEL_NAMES):
|
||||
return LEVEL_NAMES[level]
|
||||
if level >= 5:
|
||||
return "宴"
|
||||
return "?"
|
||||
|
||||
|
||||
def rate_of(achievement: float):
|
||||
"""返回 (ra, 评级码)。评级码对齐 Ichino 的 UI_TTR_Rank_*.png:D/C/B/BB/BBB/A/AA/AAA/S/Sp/SS/SSp/SSS/SSSp。"""
|
||||
raw = int(round(float(achievement) * 10000))
|
||||
raw = max(0, min(_MAX_ACH, raw))
|
||||
offset, rate = _SSSP_OFFSET, "SSSp"
|
||||
for upper, band_offset, band_rate in _BANDS:
|
||||
if raw < upper:
|
||||
offset, rate = band_offset, band_rate
|
||||
break
|
||||
return offset, rate
|
||||
|
||||
|
||||
def ra_of(ds, achievement: float):
|
||||
"""整数公式:round(ds×10) × raw × offset / 1e8,和客户端一致。"""
|
||||
if not ds:
|
||||
return 0
|
||||
offset, _ = rate_of(achievement)
|
||||
raw = max(0, min(_MAX_ACH, int(round(float(achievement) * 10000))))
|
||||
return int(round(ds * 10)) * raw * offset // 100000000
|
||||
|
||||
|
||||
def _row(item: dict, music: dict) -> dict:
|
||||
level = int(item.get("level") or 0)
|
||||
ach = int(item.get("achievement") or 0) / 10000.0
|
||||
music_id = int(item.get("musicId") or 0)
|
||||
# ★ 即使 music.json 里没有这首(music 为 None),也要走一遍覆盖表:
|
||||
# 国服新曲正是这种情况,补了定数才算得出 ra
|
||||
ds = ds_of(music, level, music_id)
|
||||
_, rate = rate_of(ach)
|
||||
return {
|
||||
"musicId": int(item.get("musicId") or 0),
|
||||
"name": (music or {}).get("name") or "",
|
||||
"level": level,
|
||||
"levelName": level_name(level),
|
||||
"ds": ds,
|
||||
"achievement": round(ach, 4),
|
||||
"ra": ra_of(ds, ach),
|
||||
"rate": rate,
|
||||
"combo": _COMBO.get(int(item.get("comboStatus") or 0), ""),
|
||||
"sync": _SYNC.get(int(item.get("syncStatus") or 0), ""),
|
||||
"rank": int(item.get("scoreRank") or 0),
|
||||
"romVersion": int(item.get("romVersion") or 0),
|
||||
"unrated": ds is None,
|
||||
}
|
||||
|
||||
|
||||
def _block(items: list, music_db: dict) -> dict:
|
||||
rows = []
|
||||
total = 0
|
||||
missing = 0
|
||||
for item in items or []:
|
||||
music = music_db.get(int(item.get("musicId") or 0))
|
||||
row = _row(item, music)
|
||||
if row["unrated"]:
|
||||
missing += 1
|
||||
total += row["ra"]
|
||||
rows.append(row)
|
||||
rows.sort(key=lambda r: (-r["ra"], -r["achievement"]))
|
||||
return {"count": len(rows), "rating": total, "missing": missing, "items": rows}
|
||||
|
||||
|
||||
def fetch(user_id: int) -> dict:
|
||||
"""只读:拿 B35 / B15 与合计。不需要登录、不需要 token。"""
|
||||
who = session.ids()
|
||||
raw = api.GetUserRating(int(user_id), client_id=who["client_id"])
|
||||
rating = (raw or {}).get("userRating") or {}
|
||||
music_db = load_music()
|
||||
|
||||
b35 = _block(rating.get("ratingList"), music_db)
|
||||
b15 = _block(rating.get("newRatingList"), music_db)
|
||||
total = b35["rating"] + b15["rating"]
|
||||
return {
|
||||
"userId": int(user_id),
|
||||
"totalRating": total,
|
||||
"b35": b35,
|
||||
"b15": b15,
|
||||
"missing": b35["missing"] + b15["missing"],
|
||||
"serverRating": int(rating.get("rating") or 0),
|
||||
"recommend": recommend(b35, b15, total, music_db),
|
||||
}
|
||||
|
||||
|
||||
# ★ 推分建议(对齐 S.Noist / EasyMai 那套口径)
|
||||
# 平均定数 = 总 rating / (22.4 × 50),推荐区间取 [平均, 平均+0.5];
|
||||
# 候选曲 = 该区间内、打到 SS+(99.5%)能超过 B35 最低分的曲目,按可涨分值排序。
|
||||
_TARGETS = (("SS+", 99.5), ("SSS", 100.0), ("SSS+", 100.5))
|
||||
|
||||
|
||||
def recommend(b35: dict, b15: dict, total: int, music_db: dict, top: int = 24) -> dict:
|
||||
r35 = [r["ra"] for r in b35["items"] if not r["unrated"]]
|
||||
ds35 = sorted(r["ds"] for r in b35["items"] if not r["unrated"] and r["ds"])
|
||||
floor_ra = min(r35) if r35 else 0
|
||||
avg = round(total / (22.4 * 50), 1) if total else 0.0
|
||||
# ★ 推荐区间用 B35 定数的**中位数**,不是 total/(22.4×50)。
|
||||
# 后者算的是「全 SSS+ 时需要的平均定数」,对低 rating 账号会低到没有候选(实测 8.1,0 首);
|
||||
# 中位数反映的是「这个号实际在打的难度」,正好是能推得动的那一档。
|
||||
med = ds35[len(ds35) // 2] if ds35 else avg
|
||||
lo = max(1.0, med)
|
||||
hi = min(15.0, round(med + 0.5, 1))
|
||||
|
||||
rows = []
|
||||
for mid, music in music_db.items():
|
||||
for level in (3, 4):
|
||||
ds = ds_of(music, level)
|
||||
if not ds or ds < lo or ds > hi:
|
||||
continue
|
||||
vals = {name: ra_of(ds, ach) for name, ach in _TARGETS}
|
||||
gain = vals["SSS+"] - floor_ra
|
||||
if gain <= 0:
|
||||
continue
|
||||
rows.append({
|
||||
"musicId": mid,
|
||||
"name": music.get("name") or "",
|
||||
"level": level,
|
||||
"levelName": level_name(level),
|
||||
"ds": ds,
|
||||
"raSSPlus": vals["SS+"],
|
||||
"raSSS": vals["SSS"],
|
||||
"raSSSPlus": vals["SSS+"],
|
||||
"gain": gain,
|
||||
"gainSSPlus": max(0, vals["SS+"] - floor_ra),
|
||||
})
|
||||
rows.sort(key=lambda r: (-r["gain"], -r["ds"]))
|
||||
# 区间内候选太少时放宽一档(低 rating 账号常常只有几首同定数)
|
||||
if len(rows) < 8:
|
||||
for mid, music in music_db.items():
|
||||
for level in (3, 4):
|
||||
ds = ds_of(music, level)
|
||||
if not ds or ds < lo or ds > min(15.0, hi + 0.5):
|
||||
continue
|
||||
if any(r["musicId"] == mid and r["level"] == level for r in rows):
|
||||
continue
|
||||
vals = {name: ra_of(ds, ach) for name, ach in _TARGETS}
|
||||
gain = vals["SSS+"] - floor_ra
|
||||
if gain <= 0:
|
||||
continue
|
||||
rows.append({
|
||||
"musicId": mid, "name": music.get("name") or "",
|
||||
"level": level, "levelName": level_name(level), "ds": ds,
|
||||
"raSSPlus": vals["SS+"], "raSSS": vals["SSS"],
|
||||
"raSSSPlus": vals["SSS+"], "gain": gain,
|
||||
"gainSSPlus": max(0, vals["SS+"] - floor_ra),
|
||||
})
|
||||
rows.sort(key=lambda r: (-r["gain"], -r["ds"]))
|
||||
|
||||
# 保守/乐观潜力:拿前 35 名的可涨分累加(B35 只有 35 个位置)
|
||||
pot_ssp = sum(r["gain"] for r in rows[:35])
|
||||
pot_sssp = sum(max(0, r["raSSSPlus"] - floor_ra) for r in rows[:35])
|
||||
return {
|
||||
"averageDs": avg,
|
||||
"dsRange": [lo, hi],
|
||||
"floorRa": floor_ra,
|
||||
"targetRA": {"SS+": ra_of(hi, 99.5), "SSS+": ra_of(hi, 100.5)},
|
||||
"potential": {"SS+": pot_ssp, "SSS+": pot_sssp},
|
||||
"songs": rows[:top],
|
||||
"total": len(rows),
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""旅行伙伴(userCharacterList)+ 出战编组(UserDetail.charaSlot)。
|
||||
|
||||
照 Ichino `models/user_character.dart` 与 `travel_partner_page.dart`:
|
||||
|
||||
· 角色不是「搭档」。搭档是 ItemKind.Partner=10,走 userItemList;
|
||||
角色的拥有状态由 `upsertUserAll.userCharacterList` 表达,
|
||||
一行四个字段 {characterId, level, awakening, useCount},主键 characterId。
|
||||
· 出战编组是 `userData[0].charaSlot`,固定 int[5],槽 0 是队长。
|
||||
· `isNewCharacterList` 是逐行位串:服务端已有同 characterId 的行走 '0',
|
||||
没有才是 '1'(客户端 BuildListData 同口径)。
|
||||
· ID 不在机台 Chara 表里时服务端照样保存,但客户端 CharacterSelectProces
|
||||
会静默跳过 —— 所以发放结果要如实回报「服务端已存,游戏内可能不显示」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import api
|
||||
|
||||
from . import session, settle
|
||||
|
||||
_upsert = importlib.import_module("api.UpsertUserAll")
|
||||
_build_user_all = _upsert.build_user_all
|
||||
|
||||
#: 旅行伙伴等级边界(Ichino UserAllPayloadBuilder.minCharacterLevel / maxCharacterLevel)
|
||||
MIN_LEVEL = 1
|
||||
MAX_LEVEL = 999999
|
||||
|
||||
|
||||
def clamp_level(value) -> int:
|
||||
"""等级钳到 1..999999 —— 客户端 `UserChara.Clear()` 初值是 1,0 会被判非法。"""
|
||||
try:
|
||||
v = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return MIN_LEVEL
|
||||
return MIN_LEVEL if v < MIN_LEVEL else (MAX_LEVEL if v > MAX_LEVEL else v)
|
||||
|
||||
|
||||
def split_level(level: int) -> tuple:
|
||||
"""真实等级 -> (界面等级, 转生次数)。
|
||||
|
||||
客户端 `UserChara` 把两项编码在一个 int 里:显示 level % 10000,
|
||||
转生 level // 10000(999999 = 99 转生 + 9999 级)。
|
||||
"""
|
||||
try:
|
||||
v = int(level)
|
||||
except (TypeError, ValueError):
|
||||
v = MIN_LEVEL
|
||||
if v < 0:
|
||||
v = 0
|
||||
return (v % 10000, v // 10000)
|
||||
|
||||
|
||||
def _user_row(snapshot: dict) -> dict:
|
||||
node = (snapshot or {}).get("userData")
|
||||
if isinstance(node, dict):
|
||||
node = node.get("userData")
|
||||
if isinstance(node, list):
|
||||
for row in node:
|
||||
if isinstance(row, dict):
|
||||
return row
|
||||
if isinstance(node, dict):
|
||||
return node
|
||||
return {}
|
||||
|
||||
|
||||
def _char_rows(snapshot: dict) -> list:
|
||||
node = (snapshot or {}).get("userCharacterList")
|
||||
if isinstance(node, dict):
|
||||
node = node.get("userCharacterList")
|
||||
rows = []
|
||||
for raw in (node or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
try:
|
||||
cid = int(raw.get("characterId") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if cid <= 0:
|
||||
continue
|
||||
level = int(raw.get("level") or 0)
|
||||
shown, rebirth = split_level(level)
|
||||
rows.append({
|
||||
"characterId": cid,
|
||||
"level": level,
|
||||
"displayLevel": shown,
|
||||
"rebirth": rebirth,
|
||||
"awakening": int(raw.get("awakening") or 0),
|
||||
"useCount": int(raw.get("useCount") or 0),
|
||||
})
|
||||
rows.sort(key=lambda r: r["characterId"])
|
||||
return rows
|
||||
|
||||
|
||||
def _slots(row: dict) -> list:
|
||||
raw = row.get("charaSlot") or []
|
||||
values = []
|
||||
for item in list(raw)[:5]:
|
||||
try:
|
||||
values.append(int(item or 0))
|
||||
except (TypeError, ValueError):
|
||||
values.append(0)
|
||||
while len(values) < 5:
|
||||
values.append(0)
|
||||
return values
|
||||
|
||||
|
||||
def read(state: dict) -> dict:
|
||||
"""只读:已拥有角色 + 当前编组。"""
|
||||
uid = int(state.get("user_id") or 0)
|
||||
token = state.get("token") or ""
|
||||
if not uid or not token:
|
||||
raise session.SessionError("还没有登录,先扫码登录")
|
||||
snap = api.GetNeedUserData(uid, token=token, save=False)
|
||||
row = _user_row(snap)
|
||||
chars = _char_rows(snap)
|
||||
return {
|
||||
"characters": chars,
|
||||
"count": len(chars),
|
||||
"slots": _slots(row),
|
||||
}
|
||||
|
||||
|
||||
def write(state: dict, *, add=(), levels=None, slots=None, dry_run: bool = True) -> dict:
|
||||
"""发放角色 / 改等级 / 编组出战槽位。
|
||||
|
||||
add: 要发放或更新的 characterId 列表
|
||||
levels: {characterId: level},不给就保持原样(新角色默认 1 级)
|
||||
slots: 长度 5 的列表,不给就不动编组
|
||||
"""
|
||||
uid = int(state.get("user_id") or 0)
|
||||
if not uid or not state.get("token"):
|
||||
raise session.SessionError("还没有登录,先扫码登录")
|
||||
add = [int(x) for x in (add or []) if int(x) > 0]
|
||||
levels = {int(k): clamp_level(v) for k, v in (levels or {}).items()
|
||||
if int(k) > 0}
|
||||
|
||||
who = session.ids()
|
||||
session.fresh(state)
|
||||
snapshot = api.GetNeedUserData(uid, token=state["token"], save=False)
|
||||
have = {row["characterId"]: row for row in _char_rows(snapshot)}
|
||||
row = _user_row(snapshot)
|
||||
|
||||
result = {
|
||||
"dryRun": bool(dry_run),
|
||||
"add": add,
|
||||
"new": [c for c in add if c not in have],
|
||||
"update": [c for c in add if c in have],
|
||||
"slotsBefore": _slots(row),
|
||||
"slotsAfter": None,
|
||||
"landed": None,
|
||||
}
|
||||
|
||||
patch = {}
|
||||
if add:
|
||||
rows = []
|
||||
flags = []
|
||||
for cid in add:
|
||||
base = dict(have.get(cid) or {"characterId": cid, "level": 1,
|
||||
"awakening": 0, "useCount": 0})
|
||||
if cid in levels:
|
||||
base["level"] = levels[cid]
|
||||
rows.append(base)
|
||||
flags.append("0" if cid in have else "1")
|
||||
patch["userCharacterList"] = rows
|
||||
patch["isNewCharacterList"] = "".join(flags)
|
||||
|
||||
play, playlog = settle.prepare(state, snapshot)
|
||||
patch.update(settle.patch(state, snapshot, play))
|
||||
|
||||
new_slots = None
|
||||
if slots is not None:
|
||||
new_slots = [int(x or 0) for x in list(slots)[:5]]
|
||||
while len(new_slots) < 5:
|
||||
new_slots.append(0)
|
||||
result["slotsAfter"] = new_slots
|
||||
# charaSlot 在 userData 节点里,整表覆盖必须给完整行
|
||||
preview = _build_user_all(
|
||||
snapshot, play, patch,
|
||||
login_date_time=int(state["login_date_time"]),
|
||||
auth_date_time=int(state["date_time"]),
|
||||
playlog_id=int(state["login_id"]),
|
||||
place_id=who["place_id"], place_name=who["place_name"],
|
||||
region_id=who["region_id"], region_name=who["region_name"],
|
||||
client_id=who["client_id"],
|
||||
)
|
||||
rows_ud = preview.get("userData") or []
|
||||
if not rows_ud or not isinstance(rows_ud[0], dict):
|
||||
raise session.SessionError("组包失败:userData 节点为空")
|
||||
merged = dict(rows_ud[0])
|
||||
merged["charaSlot"] = new_slots
|
||||
patch["userData"] = [merged]
|
||||
else:
|
||||
result["slotsAfter"] = result["slotsBefore"]
|
||||
|
||||
if dry_run:
|
||||
result["patchKeys"] = sorted(patch.keys())
|
||||
result["willSend"] = len(patch.get("userCharacterList") or [])
|
||||
return result
|
||||
|
||||
settle.ensure_play_window(state)
|
||||
settle.upload(state, playlog)
|
||||
resp = api.UpsertUserAll(
|
||||
uid, state["login_id"],
|
||||
login_date_time=state["login_date_time"],
|
||||
auth_date_time=state["date_time"],
|
||||
token=state["token"],
|
||||
snapshot=snapshot,
|
||||
play=play,
|
||||
user_playlog_list=[playlog],
|
||||
patch=patch,
|
||||
verify_landing=False,
|
||||
auto_characters=False,
|
||||
place_id=who["place_id"], place_name=who["place_name"],
|
||||
region_id=who["region_id"], region_name=who["region_name"],
|
||||
client_id=who["client_id"],
|
||||
cooldown_sec=0.0,
|
||||
dry_run=False,
|
||||
confirm=True,
|
||||
)
|
||||
result["returnCode"] = getattr(resp, "returnCode", None)
|
||||
|
||||
# 回读验收
|
||||
try:
|
||||
after = api.GetNeedUserData(uid, token=state["token"], save=False)
|
||||
now = {r["characterId"] for r in _char_rows(after)}
|
||||
result["landed"] = sorted(c for c in add if c in now)
|
||||
result["missing"] = sorted(c for c in add if c not in now)
|
||||
result["slotsNow"] = _slots(_user_row(after))
|
||||
result["ok"] = (not result["missing"]) and (
|
||||
new_slots is None or result["slotsNow"] == new_slots)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["landed"] = "回读失败:%s" % exc
|
||||
result["ok"] = None
|
||||
return result
|
||||
@@ -0,0 +1,106 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""功能票(2 / 3 / 5 倍票)—— UpsertUserChargelogApi。
|
||||
|
||||
★ 4 倍与 6 倍票已从选项里去掉:国服机台不再售这两档,留着只会让人误点。
|
||||
|
||||
★ 与「发道具」不是同一套:
|
||||
功能票走 userChargeList(GetUserChargeApi),买票走 UpsertUserChargelogApi;
|
||||
上报的 price 不是金额,而是 ticket.creditNum:
|
||||
chargeId 2 = 2倍票 price 1
|
||||
chargeId 3 = 3倍票 price 2
|
||||
chargeId 5 = 5倍票 price 4
|
||||
(依据 VOExtensions.cs:410 result.price = ticket.creditNum)
|
||||
|
||||
安全闸门(Astral 内部):
|
||||
1. chargeId 必须是付费票(ticketKind=1);
|
||||
2. 账号已持有该票(stock > 0)直接拒 —— 重复购买有封号风险;
|
||||
3. 必须先跑过 GetUserCharge(本模块开跑就先查一次,天然满足);
|
||||
4. 同一账号同一进程只允许买一次。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import api
|
||||
|
||||
from . import session, settle
|
||||
|
||||
#: chargeId -> creditNum(= 上报的 price)。Astral TICKET_CREDIT_NUM:
|
||||
#: 付费票 N -> creditNum N-1。
|
||||
#: ★ 只保留 2 / 3 / 5 三档 —— 国服机台已经不出 4 倍和 6 倍了,
|
||||
#: 买了也只是空上报,别让用户以为还能选。
|
||||
TICKETS = {
|
||||
2: {"name": "2倍票", "price": 1},
|
||||
3: {"name": "3倍票", "price": 2},
|
||||
5: {"name": "5倍票", "price": 4},
|
||||
}
|
||||
|
||||
DEFAULT_WAIT = 15.0 # 与真机节奏一致(登录 -> 买票约 15~25 秒)
|
||||
|
||||
|
||||
def ticket_name(charge_id: int) -> str:
|
||||
return TICKETS.get(int(charge_id), {}).get("name", "chargeId=" + str(charge_id))
|
||||
|
||||
|
||||
def list_charge(user_id: int) -> list:
|
||||
who = session.ids()
|
||||
raw = api.GetUserCharge(user_id, client_id=who["client_id"])
|
||||
rows = raw.get("userChargeList") if isinstance(raw, dict) else raw
|
||||
return [dict(r) for r in (rows or []) if isinstance(r, dict)]
|
||||
|
||||
|
||||
def buy(state: dict, charge_id: int, *, dry_run: bool = False,
|
||||
wait: float = DEFAULT_WAIT) -> dict:
|
||||
charge_id = int(charge_id)
|
||||
if charge_id not in TICKETS:
|
||||
raise RuntimeError("只支持 chargeId 2 / 3 / 5(2 倍 / 3 倍 / 5 倍票;4 倍与 6 倍已停用)")
|
||||
|
||||
user_id = int(state["user_id"])
|
||||
session.fresh(state)
|
||||
|
||||
before = list_charge(user_id)
|
||||
held = [c for c in before if int(c.get("stock") or 0) > 0]
|
||||
if held:
|
||||
detail = "、".join(
|
||||
"%s(chargeId=%s) stock=%s" % (ticket_name(c.get("chargeId")),
|
||||
c.get("chargeId"), c.get("stock"))
|
||||
for c in held
|
||||
)
|
||||
raise RuntimeError("账号已持有功能票:" + detail
|
||||
+ "。重复购买有封号风险,先用掉再买")
|
||||
|
||||
if wait > 0 and not dry_run:
|
||||
time.sleep(float(wait))
|
||||
|
||||
# ★ 和 UpsertUserAll 一样,登录后不足 60 秒发的写包会被服务端丢掉
|
||||
# (表现就是 returnCode=0,什么都没写)。金票买票前也走同一段等待逻辑。
|
||||
waited = settle.ensure_play_window(state)
|
||||
|
||||
who = session.ids()
|
||||
resp = api.UpsertUserChargelog(
|
||||
user_id, charge_id, state["login_date_time"],
|
||||
dry_run=bool(dry_run),
|
||||
confirm=not dry_run,
|
||||
guard_no_stock=True,
|
||||
guard_purchasable=False, # 不按机台上架清单拦
|
||||
place_id=who["place_id"],
|
||||
region_id=who["region_id"],
|
||||
client_id=who["client_id"],
|
||||
)
|
||||
|
||||
after = list_charge(user_id)
|
||||
mine = [c for c in after if int(c.get("chargeId") or 0) == charge_id]
|
||||
return {
|
||||
"chargeId": charge_id,
|
||||
"ticket": ticket_name(charge_id),
|
||||
"price": TICKETS[charge_id]["price"],
|
||||
"dryRun": bool(dry_run),
|
||||
"returnCode": getattr(resp, "returnCode", None),
|
||||
"apiName": getattr(resp, "apiName", ""),
|
||||
"before": before,
|
||||
"after": after,
|
||||
"landed": bool(mine and int(mine[0].get("stock") or 0) > 0),
|
||||
"stock": int(mine[0].get("stock") or 0) if mine else 0,
|
||||
"validDate": mine[0].get("validDate") if mine else None,
|
||||
"waited": round(float(waited), 1),
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""全曲成绩(GetUserMusicApi 全量)—— 只读。
|
||||
|
||||
链路:
|
||||
api.GetUserMusicDetailAll(userId, clientId)
|
||||
-> {"userMusicList": [{musicId, level, playCount, achievement, comboStatus, syncStatus, ...}]}
|
||||
已由 Astral 摊平成「一条 = 一首曲子的一个难度」。
|
||||
|
||||
★ 服务端响应是两层嵌套(外层按曲分组、内层按难度),Astral 的
|
||||
GetUserMusicDetailAll 已经摊平了,这里直接用。
|
||||
★ achievement 是 ×10000 的整数(和 GetUserRating 同口径),要 /10000。
|
||||
★ 定数缺失(国服新曲常见)时标记 unrated,不参与 ra 合计,也不算错误——
|
||||
可以用 data/master/ds_override.json 手工补:{"musicId": {"3": 13.8}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import api
|
||||
|
||||
from . import accounts, b50, session
|
||||
|
||||
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OVERRIDE_JSON = os.path.join(APP_DIR, "data", "master", "ds_override.json")
|
||||
|
||||
_override_cache = None
|
||||
|
||||
#: 全曲成绩缓存 —— 拉一次要翻十几页,短时间内重复点按钮没必要再拉一遍
|
||||
_CACHE_TTL = 300.0
|
||||
_cache = {"at": 0.0, "uid": 0, "data": None}
|
||||
|
||||
|
||||
def load_override() -> dict:
|
||||
"""定数覆盖表统一由 b50 管(B50 与全曲成绩必须用同一份,否则两边分数对不上)。"""
|
||||
return b50.load_override()
|
||||
|
||||
|
||||
def reload_override() -> dict:
|
||||
return b50.reload_override()
|
||||
|
||||
|
||||
def save_override(rows: dict) -> dict:
|
||||
"""**合并**写入覆盖表:{musicId: {level: ds}};ds<=0 表示删掉这一条。
|
||||
|
||||
合并而不是整体替换 —— 前端只提交刚填的那几条,不能把已有的覆盖冲掉。
|
||||
"""
|
||||
# 内层 key 统一成字符串(load_override 里是 int,直接拷贝会让 pop("3") 失效)
|
||||
current = {str(k): {str(lv): v for lv, v in vals.items()}
|
||||
for k, vals in load_override().items()}
|
||||
for mid, levels in (rows or {}).items():
|
||||
try:
|
||||
key = str(int(mid))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
slot = current.setdefault(key, {})
|
||||
for lv, ds in (levels or {}).items():
|
||||
try:
|
||||
level = str(int(lv))
|
||||
value = round(float(ds), 1)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not (0 <= int(level) <= 4):
|
||||
continue
|
||||
if value > 0:
|
||||
slot[level] = value
|
||||
else:
|
||||
slot.pop(level, None)
|
||||
if not slot:
|
||||
current.pop(key, None)
|
||||
|
||||
clean = {}
|
||||
for key, levels in current.items():
|
||||
keep = {lv: float(ds) for lv, ds in levels.items() if float(ds) > 0}
|
||||
if keep:
|
||||
clean[key] = keep
|
||||
|
||||
path = b50.OVERRIDE_JSON
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as fp:
|
||||
json.dump(clean, fp, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
reload_override()
|
||||
return clean
|
||||
|
||||
|
||||
def ds_for(music_id: int, level: int, music: dict):
|
||||
"""定数:先看手工覆盖,再查 music.json。"""
|
||||
manual = load_override().get(int(music_id), {}).get(int(level))
|
||||
if manual:
|
||||
return round(float(manual), 1)
|
||||
# 交给 b50 处理(它会先查手填覆盖表,再查曲库),music 为 None 也要走
|
||||
return b50.ds_of(music, level, music_id)
|
||||
|
||||
|
||||
def _row(item: dict, music: dict) -> dict:
|
||||
music_id = int(item.get("musicId") or 0)
|
||||
level = int(item.get("level") or 0)
|
||||
ach = int(item.get("achievement") or 0) / 10000.0
|
||||
ds = ds_for(music_id, level, music)
|
||||
_, rate = b50.rate_of(ach)
|
||||
return {
|
||||
"musicId": music_id,
|
||||
"name": (music or {}).get("name") or "",
|
||||
"level": level,
|
||||
"levelName": b50.level_name(level),
|
||||
"ds": ds,
|
||||
"achievement": round(ach, 4),
|
||||
"ra": b50.ra_of(ds, ach),
|
||||
"rate": rate,
|
||||
"combo": b50._COMBO.get(int(item.get("comboStatus") or 0), ""),
|
||||
"sync": b50._SYNC.get(int(item.get("syncStatus") or 0), ""),
|
||||
"playCount": int(item.get("playCount") or 0),
|
||||
"manualDs": ds_for(music_id, level, None) is not None,
|
||||
"unrated": ds is None,
|
||||
}
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
_cache.update({"at": 0.0, "uid": 0, "data": None})
|
||||
|
||||
|
||||
def _fetch_items(uid: int, client_id: str) -> list:
|
||||
"""拉全量成绩并摊平成「一条 = 一个难度」,每条都带 musicId。
|
||||
|
||||
★ 不能直接用 api.GetUserMusicDetailAll:服务端返回的是
|
||||
{"userMusicList": [{"musicId": N, "userMusicDetailList": [ {...}, ... ]}, ...]}
|
||||
musicId 在**组**这一层、detail 里没有。Astral 摊平时只做
|
||||
`flat.extend(group["userMusicDetailList"])`,没有把父层的 musicId 带下来 ——
|
||||
直接用会拿到一堆没有曲号的成绩,定数一律查不到、整页变成「未收录」。
|
||||
这里自己摊平并在每条 detail 上补 musicId(detail 自带就尊重它)。
|
||||
"""
|
||||
try:
|
||||
from api.UserGetterCommon import get_all_pages, UserMusicRequestVO
|
||||
except ImportError: # 老版本 Astral:退回官方封装,至少能出数据
|
||||
raw = api.GetUserMusicDetailAll(uid, client_id=client_id)
|
||||
return [dict(x) for x in ((raw or {}).get("userMusicList") or [])
|
||||
if isinstance(x, dict)]
|
||||
|
||||
groups = get_all_pages(
|
||||
"GetUserMusicApi", uid, "userMusicList",
|
||||
body_factory=lambda ni: UserMusicRequestVO(
|
||||
userId=uid, nextIndex=ni or 0, maxCount=50),
|
||||
client_id=client_id,
|
||||
)
|
||||
out = []
|
||||
for group in groups:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
mid = int(group.get("musicId") or 0)
|
||||
details = group.get("userMusicDetailList") or []
|
||||
if isinstance(details, dict):
|
||||
details = [details]
|
||||
for detail in details:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
row = dict(detail)
|
||||
if mid:
|
||||
row["musicId"] = mid
|
||||
else:
|
||||
row.setdefault("musicId", 0)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def fetch(state: dict, *, use_cache: bool = True) -> dict:
|
||||
"""拉全量成绩并算好 ra,返回统计 + 明细。默认吃 5 分钟缓存。"""
|
||||
uid = int(state.get("user_id") or 0)
|
||||
if not uid or not state.get("token"):
|
||||
raise session.SessionError("还没有登录,先扫码登录")
|
||||
if use_cache and _cache["uid"] == uid and _cache["data"] is not None \
|
||||
and time.monotonic() - _cache["at"] < _CACHE_TTL:
|
||||
return _cache["data"]
|
||||
who = session.ids()
|
||||
items = _fetch_items(uid, who["client_id"])
|
||||
|
||||
db = b50.load_music()
|
||||
rows = [_row(it, db.get(int(it.get("musicId") or 0))) for it in items
|
||||
if isinstance(it, dict)]
|
||||
rows.sort(key=lambda r: (-(r["ra"] or 0), -r["achievement"]))
|
||||
|
||||
rated = [r for r in rows if not r["unrated"]]
|
||||
by_level = {}
|
||||
for r in rows:
|
||||
slot = by_level.setdefault(r["levelName"], {"count": 0, "ra": 0,
|
||||
"fc": 0, "ap": 0, "dx": 0})
|
||||
slot["count"] += 1
|
||||
slot["ra"] = max(slot["ra"], 0) + r["ra"]
|
||||
if r["combo"] in ("fc", "fcp"):
|
||||
slot["fc"] += 1
|
||||
if r["combo"] in ("ap", "app"):
|
||||
slot["ap"] += 1
|
||||
if r["level"] >= 3:
|
||||
slot["dx"] += 1
|
||||
|
||||
out = {
|
||||
"userId": accounts.mask_user_id(uid),
|
||||
"total": len(rows),
|
||||
"rated": len(rated),
|
||||
"unrated": len(rows) - len(rated),
|
||||
"playCount": sum(r["playCount"] for r in rows),
|
||||
"apCount": sum(1 for r in rows if r["combo"] in ("ap", "app")),
|
||||
"fcCount": sum(1 for r in rows if r["combo"] in ("fc", "fcp", "ap", "app")),
|
||||
"fdxCount": sum(1 for r in rows if r["sync"] in ("fdx", "fdxp")),
|
||||
"avgAchievement": round(sum(r["achievement"] for r in rated) / len(rated), 4)
|
||||
if rated else 0.0,
|
||||
"bestRa": rated[0]["ra"] if rated else 0,
|
||||
"byLevel": by_level,
|
||||
"items": rows,
|
||||
# 定数查不到的条目单独列一份:全曲成绩里它们 ra=0,排在最后,容易被截断
|
||||
"unratedItems": [{
|
||||
"musicId": r["musicId"], "name": r["name"], "level": r["level"],
|
||||
"levelName": r["levelName"], "achievement": r["achievement"],
|
||||
"rate": r["rate"],
|
||||
} for r in rows if r["unrated"]],
|
||||
}
|
||||
_cache.update({"at": time.monotonic(), "uid": uid, "data": out})
|
||||
return out
|
||||