Compare commits
11
Commits
4959487a71
..
mai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bf36661d | ||
|
|
f6e84cf297 | ||
|
|
25ac522391 | ||
|
|
b1d7e33c7b | ||
|
|
3a0f3435f7 | ||
|
|
938341828c | ||
|
|
c537e00865 | ||
|
|
bc145fe853 | ||
|
|
0c995d3b78 | ||
|
|
ebccb9cc9a | ||
|
|
cdbdfb63fa |
@@ -28,6 +28,9 @@ download/
|
||||
get_auth_list.py
|
||||
authentication.*
|
||||
|
||||
# ---- extra_data 用的节点 JSON(含账号数据,不入库)----
|
||||
extra*.json
|
||||
|
||||
# ---- 编辑器 / 系统 ----
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<h1 align="center">WaHappy</h1>
|
||||
<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>
|
||||
@@ -11,41 +15,29 @@
|
||||
|
||||
## 这是什么
|
||||
|
||||
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
|
||||
Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
|
||||
|
||||
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
|
||||
扫码登录 → 取数据 → 上传成绩 → 登出。
|
||||
扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
|
||||
|
||||
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
|
||||
> 每个关键常量在代码注释里都标了**源码行号**。
|
||||
|
||||
---
|
||||
|
||||
## 当前状态:v0,**未完工**
|
||||
|
||||
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
|
||||
## 当前状态:v0 plus 附加功能
|
||||
|
||||
| 脚本 | 状态 |
|
||||
|---|---|
|
||||
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
|
||||
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票(2026-10-06 真机验证通过) |
|
||||
| **`use_ticket.py`** | ✅ **可用** —— 读取当前持有的功能票并用 `UpsertUserAll` 消耗掉(已实跑) |
|
||||
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生),每包 ≤4 首 + 续关登录 |
|
||||
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(ALL.Net DLI,只读,无副作用) |
|
||||
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
|
||||
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
|
||||
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
|
||||
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
|
||||
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
|
||||
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
|
||||
| **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 |
|
||||
|
||||
### 关于购买功能票(已解决)
|
||||
|
||||
`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`。根因是 **请求头多发了
|
||||
`Accept` 与 `Connection`** —— 真机(Unity Mono 的 `HttpWebRequest`)**不发这两个头**:
|
||||
|
||||
```
|
||||
number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
|
||||
```
|
||||
|
||||
删掉之后一次通过(`stock` 0 → 1,落库确认)。完整排查过程见
|
||||
[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md)。
|
||||
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
|
||||
|
||||
---
|
||||
|
||||
@@ -55,7 +47,7 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
|
||||
|
||||
| 项目 | Token 消耗 |
|
||||
|---|---|
|
||||
| **v0 有效开发** | **384M+** |
|
||||
| **v0 有效开发** | **473M+** |
|
||||
| 含中途废弃的分支项目 | **1137.5M** |
|
||||
|
||||
**这 1137.5M 里有一大半是浪费掉的。**
|
||||
@@ -72,49 +64,58 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
|
||||
## 项目框架
|
||||
|
||||
```
|
||||
WaHappy/
|
||||
├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)★ 需自建
|
||||
├── config.py.example 配置模板(机台与店铺标识留空待填)
|
||||
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 更新包(ALL.Net DLI)
|
||||
├── selftest.py 零网络自检(238 项,判据 = 源码 + 真机日志)
|
||||
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
|
||||
├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
|
||||
│
|
||||
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
|
||||
│ ├── __init__.py 统一导出 + 日志自动跟踪
|
||||
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
│ ├── log_setup.py 日志(INFO / DEBUG 双模式)
|
||||
│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分)
|
||||
│ ├── get_need_user_data.py 结算快照编排 + 落盘 data/<userId>.json
|
||||
│ ├── 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)★ 不入库
|
||||
└── source/ 反编译源码 ★ 不入库,需自备
|
||||
└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
|
||||
```
|
||||
|
||||
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
|
||||
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
|
||||
|
||||
**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`**
|
||||
(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。
|
||||
**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
|
||||
|
||||
> **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。**
|
||||
>
|
||||
> `source/` 是反编译源码,**需要自备**。
|
||||
| 服务端接口 | 脚本 | 函数 |
|
||||
|---|---|---|
|
||||
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
|
||||
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
|
||||
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
|
||||
|
||||
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
|
||||
|
||||
### 分支说明
|
||||
|
||||
| 分支 | 内容 |
|
||||
|---|---|
|
||||
| **`mai`** | 主分支 —— 代码(`api/` / `upsert.py` / `config.py` / `selftest.py` / `gamehook/`) |
|
||||
| **`mai`** | 主分支 —— 代码 |
|
||||
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
|
||||
|
||||
两个分支**没有共同的提交历史**,各自独立。
|
||||
|
||||
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
|
||||
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
|
||||
> 只是用工具的话**完全不需要**这个目录。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
@@ -134,25 +135,25 @@ cp config.py.example config.py # Linux / macOS
|
||||
copy config.py.example config.py # Windows
|
||||
```
|
||||
|
||||
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」的 6 个值**:
|
||||
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
|
||||
|
||||
| 变量 | 填什么 |
|
||||
|---|---|
|
||||
| `KEYCHIP_ID` | 你的机台 Keychip(**完整形式**,带连字符,形如 `XXXX-XXXXXXXXXXX`) |
|
||||
| `CLIENT_ID` | Keychip **短值**(去掉连字符取前 11 位) |
|
||||
| `REGION_ID` | 你所在区域 ID |
|
||||
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
|
||||
| `PLACE_ID` | 你所在店铺 ID |
|
||||
| `PLACE_NAME` | 店铺名(游戏服务器不返回,只能手填) |
|
||||
| `REGION_ID` | 你所在区域 ID |
|
||||
| `PLACE_NAME` | 店铺名 |
|
||||
| `REGION_NAME` | 区域名 |
|
||||
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
|
||||
|
||||
> ⚠️ **这 6 个值留空或填错,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。**
|
||||
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
|
||||
>
|
||||
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
|
||||
|
||||
### 3. 跑自检
|
||||
|
||||
```bash
|
||||
python selftest.py # 零网络自检,应该 238 项全绿(不依赖你的 config)
|
||||
python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
|
||||
```
|
||||
|
||||
### 主脚本一览
|
||||
@@ -160,6 +161,7 @@ python selftest.py # 零网络自检,应该 238 项全绿(不依赖
|
||||
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|
||||
|---|---|---|---|
|
||||
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
|
||||
| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
|
||||
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
|
||||
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
|
||||
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
|
||||
@@ -169,6 +171,8 @@ python selftest.py # 零网络自检,应该 238 项全绿(不依赖
|
||||
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
|
||||
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
|
||||
|
||||
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。
|
||||
|
||||
---
|
||||
|
||||
## upsert.py —— 上传一局成绩
|
||||
@@ -182,143 +186,141 @@ python upsert.py "<二维码>" --send
|
||||
|
||||
# 常用开关
|
||||
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
|
||||
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 60 秒冷却
|
||||
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
|
||||
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
|
||||
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
|
||||
```
|
||||
|
||||
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
|
||||
|
||||
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。
|
||||
|
||||
### 额外塞节点:`--extra-data`
|
||||
|
||||
真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来,
|
||||
但偶尔想手工覆盖某几个(比如本局拿到的新 KaleidxScope 门),就用 `--extra-data`:
|
||||
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
|
||||
|
||||
```bash
|
||||
# ① 从文件读(★ 推荐 —— 不用跟 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` 长这样:
|
||||
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
|
||||
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
|
||||
|
||||
```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"
|
||||
}
|
||||
> ⚠️ 在 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 | 解锁什么 |
|
||||
|---|---|
|
||||
| 键 | 必须是 `upsertUserAll` 的 **36 个节点**之一(含 11 条 `isNew*` 位串),写错会报错并列出可用节点名 |
|
||||
| 值形态 | 按节点形态强制转换,传错立刻报错 |
|
||||
| 位串 | 给了数组没给位串 → 按长度补 `"0"` 并提示。**新增条目请显式传 `"1"`**(`"0"` = 存量更新) |
|
||||
| 优先级 | 最高,覆盖快照演算结果 |
|
||||
| 校验时机 | ★ **扫码之前**就校验 —— JSON 写错直接退出,不浪费二维码 |
|
||||
| `5` | 歌曲本体(`MusicUnlockList`) |
|
||||
| `6` | Master 难度(`MusicUnlockMasterList`) |
|
||||
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
|
||||
|
||||
> ⚠ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,
|
||||
> **建议一律用文件**(`--extra-data extra.json`)。
|
||||
条目形如 `{"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 "<二维码>" --list # 登录后只列当前票券,不购买
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
|
||||
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--charge-id N` | **5** | 买哪张票 |
|
||||
| `--list` | — | 只列票券与可购买清单,不做任何写操作 |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 |
|
||||
| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) |
|
||||
|
||||
| chargeId | 票 | 上报的 `price`(= 票的 creditNum) |
|
||||
| chargeId | 票 | 上报的 `price` |
|
||||
|---|---|---|
|
||||
| 2 | 2倍チケット | 1 |
|
||||
| 3 | 3倍チケット | 2 |
|
||||
| 4 | 4倍チケット | 3 |
|
||||
| 5 | 5倍チケット | 4 |
|
||||
| 6 | 6倍チケット | 5 |
|
||||
|
||||
### 它做了哪些安全检查
|
||||
> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。
|
||||
|
||||
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
|
||||
连登录都不做(不浪费二维码)。依据 `TicketManager.cs:137-150`。
|
||||
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
|
||||
(重复购买有封号风险)。想清掉手里的票用下面的 `use_ticket.py`。
|
||||
3. **跑后再查一次**,确认票券状态符合预期。
|
||||
### 它做的安全检查
|
||||
|
||||
> ⚠️ 不同店铺上架的票不一样。**买之前先 `--list` 看一眼**,
|
||||
> 你所在的机台可能只卖其中几种。
|
||||
1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
|
||||
2. 买票前 **15 秒等待**(贴近真机节奏)
|
||||
3. 跑后再查一次确认状态
|
||||
|
||||
> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
|
||||
> (那是机台本地的上架清单,跟账号状态无关)。
|
||||
>
|
||||
> `UpsertUserChargelog` 内部还有两道硬闸门:
|
||||
> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
|
||||
> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
|
||||
|
||||
---
|
||||
|
||||
## use_ticket.py —— 消耗当前持有的功能票
|
||||
|
||||
读取账号**持有**的功能票,然后用 `UpsertUserAll` 表达「用这张票打了一局」把它消耗掉。
|
||||
|
||||
```bash
|
||||
python use_ticket.py "<二维码>" --list # 只列当前持有的票
|
||||
python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文
|
||||
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--ticket-id N` | 第一张持有的 | 消耗哪一张 |
|
||||
| `--list` | — | 只列票券,不做任何写操作 |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 |
|
||||
| `--skip-entry` | 关 | 跳过入场全量下载 |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
|
||||
### 原理
|
||||
|
||||
`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
|
||||
**格式完全相同**,所以只要把该节点里目标票的 `stock` 减 1,
|
||||
再让 playlog 带上 `useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
|
||||
|
||||
真机实证(2026-10-06 22:23 那局正好用了 2 倍券):
|
||||
|
||||
```
|
||||
userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗
|
||||
userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票
|
||||
userGamePlaylogList[0].playCredit = 2
|
||||
userPlaylogList[0].playCredit = 2
|
||||
userData.lastPlayCredit = 2
|
||||
```
|
||||
|
||||
`playCredit` 的算法(`VOExtensions.cs:838-845`):
|
||||
|
||||
```
|
||||
playCredit = 投币数(普通局 = 1) + 票的 creditNum
|
||||
```
|
||||
**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**,
|
||||
所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`,
|
||||
服务端就认为这张票被这一局用掉了。
|
||||
|
||||
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
|
||||
> 想再买一张就先把它用掉。也可以直接在机台上打一局并选用它。
|
||||
> 想再买一张就先用这个把它用掉。
|
||||
|
||||
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md)。
|
||||
|
||||
---
|
||||
|
||||
## reincarnate.py —— 账号数据迁移(转生)
|
||||
## reincarnate.py —— 账号转生
|
||||
|
||||
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
|
||||
|
||||
@@ -326,100 +328,36 @@ playCredit = 投币数(普通局 = 1) + 票的 creditNum
|
||||
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
|
||||
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
|
||||
|
||||
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 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` …)
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
|
||||
| `--src-qr` / `--src-json` | 二选一 | 源数据来源(二维码联网抓 / 读 `data/<userId>.json`) |
|
||||
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
|
||||
| `--send` | 关 | ★ 真实发包 |
|
||||
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
|
||||
| `--per-packet N` | 4 | 每包曲目数,**不能超过 4** |
|
||||
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
|
||||
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
|
||||
| `--debug` | 关 | 日志里带完整收发报文 |
|
||||
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
|
||||
| `--wait N` | 5.0 | 每次上传前的等待秒数 |
|
||||
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) |
|
||||
|
||||
### 流程
|
||||
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
|
||||
|
||||
```
|
||||
源账号 A:二维码 → GetNeedUserData(并落盘 data/<A>.json)
|
||||
或直接读 data/<A>.json
|
||||
↓
|
||||
目标账号 B:二维码 → UserPreview → UserLogin
|
||||
循环:
|
||||
UpsertUserAll(每包 ≤4 首 + 从曲目记录反推的 playlog)
|
||||
UserLogin(is_continue=True) ← ★ 续关,换新 loginId
|
||||
收尾:UserLogout
|
||||
```
|
||||
|
||||
### 为什么一次最多 4 首
|
||||
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
|
||||
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
|
||||
本脚本把源账号的 `userMusicDetailList` 按 4 条一组切分,一组 = 一局。
|
||||
|
||||
### 为什么要「续关登录」
|
||||
|
||||
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`。
|
||||
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||
真机走的就是「续关」(`Process/ContinueProcess.cs:403`):
|
||||
|
||||
```csharp
|
||||
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||
```
|
||||
|
||||
本工程对应 `UserLogin(user_id, token, is_continue=True)`。
|
||||
|
||||
### playlog 从哪来
|
||||
|
||||
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
|
||||
所以本脚本**从曲目记录反推合成**:
|
||||
|
||||
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|
||||
|---|---|---|
|
||||
| `musicId` / `level` | | `musicId` / `level` |
|
||||
| `achievement` | | `achievement` |
|
||||
| `deluxscoreMax` | | `deluxscore` |
|
||||
| `scoreRank` | | `scoreRank` / `isClear` |
|
||||
| `comboStatus` / `syncStatus` | | 同名 |
|
||||
| 其余 | | `config.PLAY_*` 默认值 |
|
||||
|
||||
### 迁移范围
|
||||
|
||||
默认迁**全部节点**(整账号克隆)。第 1 包带全部非曲目节点,之后每包只带这一批曲目;
|
||||
位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`)。
|
||||
|
||||
> ⚠️ **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
|
||||
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md)。
|
||||
|
||||
---
|
||||
|
||||
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
|
||||
|
||||
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」——
|
||||
这就是 **DLI(Download Instruction,配信指示書)**:
|
||||
|
||||
```
|
||||
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||
|
||||
→ result=1&uri=<patch 指示書>|<option 指示書>
|
||||
```
|
||||
|
||||
指示書本身是个 INI,真正要下的包写在 `INSTALLn=` 里:
|
||||
|
||||
```ini
|
||||
[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=` 是「本机应该已经有的文件」,
|
||||
> 只是文件名,不是下载项。
|
||||
|
||||
```bash
|
||||
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
|
||||
python netdelivery.py A63E01ABCDE # 指定 Keychip
|
||||
@@ -428,98 +366,16 @@ 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`=退出。
|
||||
下载带**断点续传**(`.part` 临时文件 + `Range`,下完改名)与进度/速率显示。
|
||||
下载带**断点续传**与进度/速率显示,默认存到 `download/`(不入库)。
|
||||
|
||||
实测(`--ver 1.56`):
|
||||
|
||||
```
|
||||
序号 类型 大小 来源段/键 文件名
|
||||
[ 1] opt 7.25 MB COMMON/INSTALL1 SDGB_A031_20260909101347_0.opt
|
||||
[ 2] opt 3.00 MB OPTIONAL/INSTALL1 SDGB_A005_20260610180958_0.opt
|
||||
```
|
||||
|
||||
### 几点实测结论
|
||||
|
||||
| 结论 | 说明 |
|
||||
|---|---|
|
||||
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
|
||||
| 包可以断点续传 | 返回 `206` + `Content-Range` |
|
||||
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
|
||||
|
||||
> 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
|
||||
> 这是**只读**操作,不写任何账号数据。
|
||||
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
|
||||
|
||||
---
|
||||
|
||||
## 常见坑与注意事项
|
||||
|
||||
### 二维码
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2`(**不是按次数计量的**,重新生成即可) |
|
||||
| 来源 | 机台的「扫码登录」界面 |
|
||||
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
|
||||
|
||||
### 账号状态
|
||||
|
||||
- **一定要登出**。账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
|
||||
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
|
||||
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
|
||||
|
||||
### 功能票
|
||||
|
||||
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
|
||||
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
|
||||
- 不同店铺上架的票不一样,买之前先 `--list`。
|
||||
|
||||
### 副作用
|
||||
|
||||
| 命令 | 后果 |
|
||||
|---|---|
|
||||
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
|
||||
| 带 `--send` | **真实写入服务器,不可撤销** |
|
||||
|
||||
### 日志
|
||||
|
||||
```
|
||||
logs/latest.log 只含**当次**运行(下次覆盖)
|
||||
logs/YYYY-MM-DD.log 当日全部日志(追加)
|
||||
```
|
||||
|
||||
改 `config.py` 的 `LOG_LEVEL`:
|
||||
|
||||
| 值 | 效果 |
|
||||
|---|---|
|
||||
| `"info"` | 只展示 api 脚本的运行情况和结果 |
|
||||
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
|
||||
|
||||
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
|
||||
|
||||
### 网络
|
||||
|
||||
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
|
||||
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
|
||||
|
||||
### 不要提交的东西
|
||||
|
||||
```
|
||||
config.py 你的机台身份,.gitignore 已忽略
|
||||
data/ 用户数据快照
|
||||
download/ .app / .opt 更新包
|
||||
source/ 反编译源码(版权)
|
||||
logs/ 运行日志
|
||||
```
|
||||
|
||||
> ⚠️ **`.gitignore` 的行尾注释不是注释**。写 `data/ # 注释` 会变成一条匹配不到任何东西的规则
|
||||
> —— 这条坑踩过一次(用户数据被误提交),已修。**注释必须独占一行。**
|
||||
|
||||
---
|
||||
|
||||
## 主脚本怎么写
|
||||
|
||||
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
|
||||
@@ -529,7 +385,7 @@ logs/ 运行日志
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
from api import (setup_logging, teardown_logging,
|
||||
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
|
||||
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
|
||||
UpsertUserAll)
|
||||
from config import REGION_ID, PLACE_ID
|
||||
|
||||
@@ -540,7 +396,7 @@ def main() -> int:
|
||||
user_id, token = ParseQRCode(sys.argv[1])
|
||||
session.update(user_id=user_id, token=token)
|
||||
|
||||
UserPreview(user_id, 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)
|
||||
@@ -555,7 +411,7 @@ def main() -> int:
|
||||
finally:
|
||||
if session["logged_in"]: # ★ 一定要保证登出
|
||||
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||
logout_type=LogoutType.TestIn,
|
||||
logout_type=LogoutType.Logout,
|
||||
region_id=REGION_ID, place_id=PLACE_ID)
|
||||
teardown_logging()
|
||||
|
||||
@@ -569,7 +425,7 @@ if __name__ == "__main__":
|
||||
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
|
||||
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
|
||||
|
||||
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md)。
|
||||
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -579,12 +435,13 @@ if __name__ == "__main__":
|
||||
|
||||
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
|
||||
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
|
||||
- ✅ 购买功能票(真机验证通过)
|
||||
- ✅ 消耗持有的功能票(已实跑)
|
||||
- ✅ 购买功能票
|
||||
- ✅ 消耗持有的功能票
|
||||
- ✅ 账号数据快照落盘(`data/<userId>.json`)
|
||||
- ✅ 账号数据迁移(转生)
|
||||
- ✅ 查 / 下 `.app` / `.opt` 更新包(ALL.Net DLI)
|
||||
- ✅ 零网络自检 238 项
|
||||
- ✅ 查 / 下 `.app` / `.opt` 更新包
|
||||
- ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
|
||||
- ✅ 零网络自检 284 项
|
||||
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
|
||||
|
||||
**将来可能会加**
|
||||
@@ -596,15 +453,39 @@ if __name__ == "__main__":
|
||||
|
||||
---
|
||||
|
||||
## 文档索引
|
||||
|
||||
全部在 [`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
|
||||
- **WorkBuddyAI**([workbuddy.ai](https://www.workbuddy.ai/))—— 提供 AI 模型
|
||||
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
|
||||
- **DeepSeek V4.1 Flash** —— 主要代码编写
|
||||
- **NVIDIA NIM**([build.nvidia.com/models](https://build.nvidia.com/models))、
|
||||
**AMD**([developer.amd.com.cn/radeon/tokenfactory](https://developer.amd.com.cn/radeon/tokenfactory))、
|
||||
**Kilo**([kilo.ai](https://kilo.ai/))—— 提供 AI 模型
|
||||
- **eaquira**([git.fragrance.moe/Fragrance/eaquira](https://git.fragrance.moe/Fragrance/eaquira))—— 思路启发
|
||||
- **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)
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
chime.py —— 扫码登录(二维码 -> userId + token)
|
||||
Chime.py —— 扫码登录(二维码 -> userId + token)
|
||||
================================================================================
|
||||
|
||||
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
|
||||
@@ -76,7 +76,7 @@ QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_request, log_response
|
||||
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 二维码格式常量(CCommQRCode.cs + 技术规范)
|
||||
@@ -288,7 +288,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chime_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_charge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的计费档位。
|
||||
@@ -22,8 +22,8 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_settings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
|
||||
================================================================================
|
||||
|
||||
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
|
||||
@@ -25,8 +25,8 @@ from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -154,7 +154,7 @@ def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("gamesettings_test")
|
||||
@@ -166,7 +166,7 @@ if __name__ == "__main__":
|
||||
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
|
||||
return 0
|
||||
except Exception as exc:
|
||||
from .log_setup import log_error
|
||||
from .LogSetup import log_error
|
||||
log_error("获取游戏设置失败:" + str(exc))
|
||||
return 1
|
||||
finally:
|
||||
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_need_user_data.py —— 结算快照编排(统一调用各取数脚本)
|
||||
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
|
||||
================================================================================
|
||||
|
||||
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
|
||||
|
||||
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(get_user_data.py / get_user_map.py …)。
|
||||
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
为什么需要快照
|
||||
@@ -41,29 +41,29 @@ from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .LogSetup import log_debug, log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
|
||||
# 各取数脚本(一个接口一个脚本)
|
||||
from .get_user_data import GetUserData
|
||||
from .get_user_extend import GetUserExtend
|
||||
from .get_user_option import GetUserOption
|
||||
from .get_user_character import GetUserCharacter
|
||||
from .get_user_ghost import GetUserGhost
|
||||
from .get_user_map import GetUserMapAll
|
||||
from .get_user_login_bonus import GetUserLoginBonusAll
|
||||
from .get_user_rating import GetUserRating
|
||||
from .get_user_item import GetUserItemAll
|
||||
from .get_user_music import GetUserMusicDetailAll
|
||||
from .get_user_course import GetUserCourseAll
|
||||
from .get_user_charge import GetUserCharge
|
||||
from .get_user_favorite import GetUserFavoriteAll
|
||||
from .get_user_activity import GetUserActivity
|
||||
from .get_user_mission import GetUserMissionData
|
||||
from .get_user_intimate import GetUserIntimate
|
||||
from .get_user_shop_stock import GetUserShopStock
|
||||
from .get_user_favorite_item import GetUserFavoriteItemAll
|
||||
from .get_user_kaleidx_scope import GetUserKaleidxScope
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -372,7 +372,7 @@ def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import log_error, setup_logging, teardown_logging
|
||||
from .LogSetup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("get_need_user_data_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_activity.py —— 取用户活动记录(GetUserActivityApi)
|
||||
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
|
||||
@@ -25,9 +25,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserActivity(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_character.py —— 取用户角色列表(GetUserCharacterApi)
|
||||
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
|
||||
================================================================================
|
||||
|
||||
取玩家所有角色的等级与觉醒度(真机 312 条)。
|
||||
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserCharacter(
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
|
||||
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
|
||||
@@ -21,9 +21,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserCourseRequestVO, get_all_pages
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserCourse(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_data.py —— 取用户全量数据(GetUserDataApi)
|
||||
GetUserData.py —— 取用户全量数据(GetUserDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
|
||||
@@ -29,9 +29,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserData(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_extend.py —— 取用户扩展设置(GetUserExtendApi)
|
||||
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
|
||||
================================================================================
|
||||
|
||||
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
|
||||
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserExtend(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
|
||||
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
|
||||
@@ -24,9 +24,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
|
||||
|
||||
|
||||
def GetUserFavorite(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
|
||||
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
|
||||
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserFavoriteItemRequestVO, get_all_pages
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
|
||||
|
||||
#: kind 取值
|
||||
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
|
||||
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家本次可领取的好友里程(getMiles)。
|
||||
@@ -21,9 +21,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserFriendBonus(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
|
||||
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的「幽灵」对战记录(真机 15 条)。
|
||||
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserGhost(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
|
||||
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
|
||||
================================================================================
|
||||
|
||||
取玩家与各搭档的亲密度(真机 2 条)。
|
||||
@@ -20,9 +20,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserIntimate(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_item.py —— 取用户道具(GetUserItemApi)
|
||||
GetUserItem.py —— 取用户道具(GetUserItemApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
|
||||
@@ -38,9 +38,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
|
||||
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(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
|
||||
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
@@ -17,7 +17,7 @@ get_user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi
|
||||
★★ 重要区分(曾导致 userData 取空)
|
||||
--------------------------------------------------------------------------------
|
||||
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
|
||||
玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。
|
||||
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
|
||||
|
||||
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
|
||||
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
|
||||
@@ -33,9 +33,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserKaleidxScope(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
|
||||
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的登录奖励领取进度(真机 38 条,分两页)。
|
||||
@@ -22,9 +22,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserLoginBonusRequestVO, get_all_pages
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserLoginBonus(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_map.py —— 取用户地图进度(GetUserMapApi)
|
||||
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
|
||||
================================================================================
|
||||
|
||||
取玩家各地图的推进距离(真机 104 条)。
|
||||
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdPageRequestVO, get_all_pages
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserMap(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
|
||||
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的任务进度与周常数据(真机 6 条任务)。
|
||||
@@ -25,9 +25,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserMissionData(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_music.py —— 取用户曲目成绩(GetUserMusicApi)
|
||||
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家全部曲目的历史成绩归档(真机 739 组)。
|
||||
@@ -27,9 +27,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserMusicRequestVO, get_all_pages
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
|
||||
|
||||
|
||||
def GetUserMusicDetail(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_new_item.py —— 取新增道具(GetUserNewItemListApi)
|
||||
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
|
||||
================================================================================
|
||||
|
||||
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
|
||||
@@ -25,8 +25,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_option.py —— 取用户游戏设置(GetUserOptionApi)
|
||||
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
|
||||
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserOption(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_preview.py —— 用户预览(GetUserPreviewApi)
|
||||
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
|
||||
================================================================================
|
||||
|
||||
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
|
||||
@@ -29,8 +29,8 @@ from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -133,7 +133,7 @@ def GetUserPreview(
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import log_error, setup_logging, teardown_logging
|
||||
from .LogSetup import log_error, setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userpreview_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
|
||||
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
|
||||
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRating(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
|
||||
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
|
||||
================================================================================
|
||||
|
||||
对应接口:
|
||||
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRecommendRateMusic(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
|
||||
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各地区的游玩次数。
|
||||
@@ -21,9 +21,9 @@ from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserIdRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserIdRequestVO
|
||||
|
||||
|
||||
def GetUserRegion(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
|
||||
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的对手名单及其曲目成绩。
|
||||
@@ -28,9 +28,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import (
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import (
|
||||
_get,
|
||||
UserRivalRequestVO,
|
||||
UserRivalMusicRequestVO,
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
|
||||
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
|
||||
================================================================================
|
||||
|
||||
取玩家在各商店商品的已兑换次数。
|
||||
@@ -24,9 +24,9 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common import _get, UserShopStockRequestVO
|
||||
from .LogSetup import log_info
|
||||
from .MaimaiNet import MaimaiNetClient
|
||||
from .UserGetterCommon import _get, UserShopStockRequestVO
|
||||
|
||||
|
||||
def GetUserShopStock(
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
|
||||
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
|
||||
================================================================================
|
||||
|
||||
【两种模式】
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
|
||||
================================================================================
|
||||
|
||||
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
|
||||
@@ -133,7 +133,7 @@ AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_request, log_response
|
||||
from .LogSetup import log_debug, log_error, log_request, log_response
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ping.py —— 心跳探活(PingMaimaiChn)
|
||||
Ping.py —— 心跳探活(PingMaimaiChn)
|
||||
================================================================================
|
||||
|
||||
功能:向游戏服务器发一次心跳,确认链路可达。
|
||||
@@ -41,8 +41,8 @@ from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_info, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
def Ping(
|
||||
@@ -80,7 +80,7 @@ def Ping(
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("ping_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
upsert_user_all.py —— 上传成绩(UpsertUserAllApi)
|
||||
UpsertUserAll.py —— 上传成绩(UpsertUserAllApi)
|
||||
================================================================================
|
||||
|
||||
功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。
|
||||
@@ -158,8 +158,8 @@ PLAY_AFTER_GRADE_RANK = 1
|
||||
#: True = 从曲目记录推(仅调试用)
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL = False
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import (
|
||||
from .LogSetup import log_debug, log_error, log_info, log_warning
|
||||
from .MaimaiNet import (
|
||||
MaimaiNetClient,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
@@ -552,7 +552,7 @@ def build_favorite_delta(
|
||||
"2" = 服务器侧有对应 itemKind(无论内容是否一致)
|
||||
"1" = 服务器侧查不到该 itemKind
|
||||
|
||||
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 get_user_favorite.py
|
||||
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 GetUserFavorite.py
|
||||
的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是
|
||||
{"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。
|
||||
|
||||
@@ -1894,6 +1894,117 @@ def apply_play_effects(
|
||||
# 十二、组装 upsertUserAll
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# ★ 歌曲解锁(解禁):MusicUnlock* 不是独立节点,而是并入 userItemList 的 UserItem
|
||||
# -----------------------------------------------------------------------------
|
||||
# 依据 source/Assembly-CSharp/Manager/UserData.cs:132-138 —— 客户端有四个解锁列表:
|
||||
# MusicUnlockList / MusicUnlockMasterList / MusicUnlockReMasterList / MusicUnlockStrongList
|
||||
# 它们**没有**对应的 userXxxList 节点,导出时统一并进 userItemList:
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
|
||||
# MusicUnlockList -> itemKind = 5
|
||||
# MusicUnlockMasterList -> itemKind = 6
|
||||
# MusicUnlockReMasterList -> itemKind = 7
|
||||
# MusicUnlockStrongList -> itemKind = 8
|
||||
# 条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
|
||||
# (真机 data/12570152.json 的 userItemList 里 kind5=77 条、kind6=446 条、kind7=34 条)
|
||||
#
|
||||
# ★ 难度解锁的「天生免费」规则:
|
||||
# source/Assembly-CSharp/Manager/NotesListManager.cs:415-453 IsUnlockMaster
|
||||
# source/Assembly-CSharp/Manager/NotesListManager.cs:455-494 IsUnlockReMaster
|
||||
# 两个函数开头都有 `if (id < 10000) return true;`
|
||||
# -> musicId < 10000(标准谱)的 Master / Re:Master **天生解锁**,不必写 kind6/7;
|
||||
# 只有 musicId >= 10000(DX 谱)才需要额外解锁。
|
||||
# ★ 另外 Re:Master 还受活动开关约束(IsUnlockReMaster 里的
|
||||
# `if (!instance.IsOpenEvent(subEventId)) return false;`),
|
||||
# 那是机台/服务端侧的开关,脚本改不了 —— 写了 kind7 也可能进游戏仍是灰的。
|
||||
#
|
||||
# ★ 写 kind5 只是「把这首歌标记为已解锁」,不携带任何成绩;
|
||||
# 成绩另由 userMusicDetailList 承载,纯解锁不需要打歌。
|
||||
|
||||
#: UserItem.itemKind 取值(source/Assembly-CSharp/Manager/UserData.cs:132-138)
|
||||
ITEM_KIND_MUSIC_UNLOCK = 5
|
||||
ITEM_KIND_MUSIC_UNLOCK_MASTER = 6
|
||||
ITEM_KIND_MUSIC_UNLOCK_REMASTER = 7
|
||||
ITEM_KIND_MUSIC_UNLOCK_STRONG = 8
|
||||
|
||||
#: 小于这个 musicId 的谱面 Master / Re:Master 天生解锁(NotesListManager.cs:415/455)
|
||||
UNLOCK_MASTER_MIN_MUSIC_ID = 10000
|
||||
|
||||
|
||||
def build_unlock_items(
|
||||
music_ids: Sequence[int],
|
||||
existing: Optional[Sequence[Any]] = None,
|
||||
*,
|
||||
master: bool = True,
|
||||
remaster: bool = True,
|
||||
unlock_base: bool = True,
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""
|
||||
构造「解锁歌曲 / 解禁难度」要追加的 userItemList 条目。
|
||||
|
||||
★ 只返回**服务器上还没有**的条目;已存在的跳过(重复插入会让服务端报错)。
|
||||
|
||||
参数:
|
||||
music_ids: 要解锁的 musicId 列表(重复的自动去掉)
|
||||
existing: 服务器现有的 userItemList(GetUserData 里的那份),
|
||||
用来判断哪些条目已经存在
|
||||
master: True = 同时解锁 Master 难度(仅 musicId >= 10000 才写 kind6)
|
||||
remaster: True = 同时解锁 Re:Master 难度(仅 musicId >= 10000 才写 kind7)
|
||||
unlock_base: True = 解锁歌曲本体(kind5)
|
||||
|
||||
返回:
|
||||
(条目列表, 说明文字列表)
|
||||
条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
|
||||
"""
|
||||
have: set = set()
|
||||
for it in existing or []:
|
||||
if isinstance(it, dict):
|
||||
try:
|
||||
have.add((int(it.get("itemKind")), int(it.get("itemId"))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
ids: List[int] = []
|
||||
for raw in music_ids or []:
|
||||
try:
|
||||
mid = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if mid <= 0 or mid in ids:
|
||||
continue
|
||||
ids.append(mid)
|
||||
|
||||
items: List[Dict[str, Any]] = []
|
||||
notes: List[str] = []
|
||||
|
||||
def _add(kind: int, mid: int, what: str) -> None:
|
||||
if (kind, mid) in have:
|
||||
notes.append("解锁检查:musicId=" + str(mid) + " 的" + what
|
||||
+ "已经解锁(itemKind=" + str(kind) + "),跳过")
|
||||
return
|
||||
have.add((kind, mid))
|
||||
items.append({"itemKind": kind, "itemId": mid, "stock": 1, "isValid": True})
|
||||
notes.append("解锁:musicId=" + str(mid) + " " + what
|
||||
+ "(itemKind=" + str(kind) + ")")
|
||||
|
||||
for mid in ids:
|
||||
if unlock_base:
|
||||
_add(ITEM_KIND_MUSIC_UNLOCK, mid, "歌曲本体")
|
||||
if mid < UNLOCK_MASTER_MIN_MUSIC_ID:
|
||||
# ★ 标准谱(musicId < 10000)Master/Re:Master 天生解锁,不需要条目
|
||||
if master or remaster:
|
||||
notes.append("解锁检查:musicId=" + str(mid)
|
||||
+ " 是标准谱(< " + str(UNLOCK_MASTER_MIN_MUSIC_ID)
|
||||
+ "),Master/Re:Master 天生解锁,跳过")
|
||||
continue
|
||||
if master:
|
||||
_add(ITEM_KIND_MUSIC_UNLOCK_MASTER, mid, "Master 难度")
|
||||
if remaster:
|
||||
_add(ITEM_KIND_MUSIC_UNLOCK_REMASTER, mid, "Re:Master 难度")
|
||||
|
||||
return items, notes
|
||||
|
||||
|
||||
def build_user_all(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
@@ -1909,6 +2020,9 @@ def build_user_all(
|
||||
playlog_id: int = 0,
|
||||
play_date_time: Optional[int] = None,
|
||||
extra_data: Optional[Any] = None,
|
||||
unlock_music_ids: Optional[Sequence[int]] = None,
|
||||
unlock_master: bool = True,
|
||||
unlock_remaster: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
组装 upsertUserAll 的 36 个节点。
|
||||
@@ -1932,6 +2046,10 @@ def build_user_all(
|
||||
playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList)
|
||||
extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径),
|
||||
自动匹配插入 36 节点,**优先级高于 patch**。见 merge_extra_data。
|
||||
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表。见 build_unlock_items。
|
||||
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
|
||||
unlock_master: 解锁时是否一并解禁 Master(仅 DX 谱 musicId >= 10000 生效)
|
||||
unlock_remaster: 解锁时是否一并解禁 Re:Master(同上)
|
||||
|
||||
返回:
|
||||
36 节点的 dict
|
||||
@@ -2039,6 +2157,34 @@ def build_user_all(
|
||||
for note in merge_extra_data(user_all, extra_data):
|
||||
log_info(note)
|
||||
|
||||
# ---- ★ 歌曲解锁(解禁)----
|
||||
# 放在 extra_data 之后:extra_data 可能整体替换了 userItemList,
|
||||
# 解锁条目追加到最终列表末尾,位串补 "1"(= 服务端侧新增该 item)。
|
||||
# 依据见本文件「歌曲解锁」注释块。
|
||||
if unlock_music_ids:
|
||||
unlock_items, unlock_notes = build_unlock_items(
|
||||
unlock_music_ids,
|
||||
existing=user_all.get("userItemList") or [],
|
||||
master=unlock_master,
|
||||
remaster=unlock_remaster,
|
||||
)
|
||||
for note in unlock_notes:
|
||||
log_info(note)
|
||||
if unlock_items:
|
||||
old_items = list(user_all.get("userItemList") or [])
|
||||
old_bits = user_all.get("isNewItemList")
|
||||
if not isinstance(old_bits, str):
|
||||
old_bits = ""
|
||||
if len(old_bits) != len(old_items):
|
||||
# 先对齐到旧列表长度,避免新条目被 sync_bits_to_arrays 补成 "0"
|
||||
old_bits = (old_bits + "0" * len(old_items))[:len(old_items)]
|
||||
user_all["userItemList"] = old_items + unlock_items
|
||||
user_all["isNewItemList"] = old_bits + "1" * len(unlock_items)
|
||||
log_info("★ 本次解锁共新增 " + str(len(unlock_items))
|
||||
+ " 个 userItem 条目(位串补 " + "1" * len(unlock_items) + ")")
|
||||
else:
|
||||
log_info("解锁检查:指定的歌曲/难度在服务器上都已经解锁,无需改动")
|
||||
|
||||
# ---- 清理内部字段 ----
|
||||
for key in list(user_all.keys()):
|
||||
if key.startswith("_"):
|
||||
@@ -2350,16 +2496,16 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
|
||||
def _lazy(name: str) -> Any:
|
||||
"""按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。"""
|
||||
if name == "GetNeedUserData":
|
||||
from .get_need_user_data import GetNeedUserData
|
||||
from .GetNeedUserData import GetNeedUserData
|
||||
return GetNeedUserData
|
||||
if name == "GetUserNewItemList":
|
||||
from .get_user_new_item import GetUserNewItemList
|
||||
from .GetUserNewItem import GetUserNewItemList
|
||||
return GetUserNewItemList
|
||||
if name == "GetUserData":
|
||||
from .get_user_data import GetUserData
|
||||
from .GetUserData import GetUserData
|
||||
return GetUserData
|
||||
if name == "GetUserMusicDetailAll":
|
||||
from .get_user_music import GetUserMusicDetailAll
|
||||
from .GetUserMusic import GetUserMusicDetailAll
|
||||
return GetUserMusicDetailAll
|
||||
raise PatchError("未知的内部 api:" + str(name))
|
||||
|
||||
@@ -2478,6 +2624,9 @@ def UpsertUserAll(
|
||||
auto_characters: bool = True,
|
||||
play: Optional[PlayData] = None,
|
||||
extra_data: Optional[Any] = None,
|
||||
unlock_music_ids: Optional[Sequence[int]] = None,
|
||||
unlock_master: bool = True,
|
||||
unlock_remaster: bool = True,
|
||||
patch: Optional[Dict[str, Any]] = None,
|
||||
user_playlog_list: Optional[List[Any]] = None,
|
||||
pre_new_item_list: bool = True,
|
||||
@@ -2533,6 +2682,14 @@ def UpsertUserAll(
|
||||
"isNewKaleidxScopeList": "1"}
|
||||
优先级最高(覆盖快照演算出的同名字段)。
|
||||
没给位串时会按数组长度补 "0" 并给出提示。
|
||||
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表,例如 [1085, 11414]。
|
||||
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
|
||||
★ musicId < 10000(标准谱)的 Master/Re:Master 天生解锁,
|
||||
只写 kind5;只有 DX 谱(>= 10000)才写 kind6/7。
|
||||
★ 已存在的条目自动跳过(重复插入服务端会报错)。
|
||||
见 build_unlock_items 上方的源码依据。
|
||||
unlock_master: 解锁时是否一并解禁 Master 难度(默认 True)
|
||||
unlock_remaster: 解锁时是否一并解禁 Re:Master 难度(默认 True)
|
||||
patch: 同 extra_data 的旧接口(只收 dict,不补位串提示)。
|
||||
user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条)
|
||||
pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi
|
||||
@@ -2624,6 +2781,8 @@ def UpsertUserAll(
|
||||
# ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)----------
|
||||
user_all = build_user_all(
|
||||
snapshot=snapshot, play=p, patch=patch, extra_data=extra_data,
|
||||
unlock_music_ids=unlock_music_ids,
|
||||
unlock_master=unlock_master, unlock_remaster=unlock_remaster,
|
||||
login_date_time=login_date_time, auth_date_time=auth_date_time,
|
||||
place_id=place_id, place_name=place_name,
|
||||
region_id=region_id, region_name=region_name, client_id=client_id,
|
||||
@@ -2709,7 +2868,7 @@ def UpsertUserAll(
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("upsert_user_all_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""
|
||||
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
||||
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
|
||||
================================================================================
|
||||
|
||||
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
|
||||
@@ -140,9 +140,15 @@ PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
||||
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):
|
||||
@@ -334,6 +340,8 @@ def UpsertUserChargelog(
|
||||
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,
|
||||
@@ -368,6 +376,16 @@ def UpsertUserChargelog(
|
||||
(抛 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 = 只构造并返回报文,绝不发包
|
||||
|
||||
@@ -387,6 +405,34 @@ def UpsertUserChargelog(
|
||||
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,自动抓取当前票券 ...")
|
||||
@@ -540,6 +586,8 @@ def UpsertUserChargelog(
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -548,7 +596,7 @@ def UpsertUserChargelog(
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("chargelog_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
user_getter_common.py —— 各取数脚本的公共底座
|
||||
UserGetterCommon.py —— 各取数脚本的公共底座
|
||||
================================================================================
|
||||
|
||||
只提供三样东西,取数逻辑都在各自的脚本里:
|
||||
@@ -36,8 +36,8 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_warning
|
||||
from .maimai_net import MaimaiNetClient, get_default_client
|
||||
from .LogSetup import log_debug, log_warning
|
||||
from .MaimaiNet import MaimaiNetClient, get_default_client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userlogin.py —— 用户正式登录(UserLoginApi)
|
||||
UserLogin.py —— 用户正式登录(UserLoginApi)
|
||||
================================================================================
|
||||
|
||||
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
|
||||
@@ -48,8 +48,8 @@ from typing import Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info
|
||||
from .maimai_net import (
|
||||
from .LogSetup import log_debug, log_error, log_info
|
||||
from .MaimaiNet import (
|
||||
MaimaiNetClient,
|
||||
local_utc_offset_seconds,
|
||||
login_date_time_now,
|
||||
@@ -198,7 +198,7 @@ def UserLogin(
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogin_test")
|
||||
@@ -1,6 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
userlogout.py —— 用户登出(UserLogoutApi)
|
||||
UserLogout.py —— 用户登出(UserLogoutApi)
|
||||
================================================================================
|
||||
|
||||
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
|
||||
@@ -39,8 +39,8 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .LogSetup import log_debug, log_error, log_info
|
||||
from .MaimaiNet import MaimaiNetClient, request_dict
|
||||
|
||||
#: DelayLog.request 的定长(DelayLog.cs:17)
|
||||
DELAY_LOG_SLOTS = 26
|
||||
@@ -228,7 +228,7 @@ def UserLogout(
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
from .log_setup import setup_logging, teardown_logging
|
||||
from .LogSetup import setup_logging, teardown_logging
|
||||
|
||||
def _main() -> int:
|
||||
setup_logging("userlogout_test")
|
||||
+111
-78
@@ -20,59 +20,60 @@ api 包 —— 统一导出
|
||||
目录结构(一个 api 一个脚本)
|
||||
--------------------------------------------------------------------------------
|
||||
基础设施
|
||||
log_setup.py 统一日志(INFO / DEBUG 双模式)
|
||||
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
|
||||
LogSetup.py 统一日志(INFO / DEBUG 双模式)
|
||||
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
|
||||
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
|
||||
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
|
||||
|
||||
会话链路
|
||||
chime.py 扫码登录 -> userId + token
|
||||
get_game_settings.py GetGameSettingApi(游戏设置)
|
||||
ping.py 心跳探活
|
||||
get_user_preview.py GetUserPreviewApi(用户预览)
|
||||
userlogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
|
||||
userlogout.py UserLogoutApi(登出)
|
||||
Chime.py 扫码登录 -> userId + token
|
||||
GetGameSettings.py GetGameSettingApi(游戏设置)
|
||||
Ping.py 心跳探活
|
||||
GetUserPreview.py GetUserPreviewApi(用户预览)
|
||||
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
|
||||
UserLogout.py UserLogoutApi(登出)
|
||||
|
||||
上传 / 结算(有副作用的接口)
|
||||
upsert_user_all.py ★ UpsertUserAllApi(上传成绩)
|
||||
upsert_user_chargelog.py UpsertUserChargelogApi(购买功能票)
|
||||
get_need_user_data.py ★ 结算快照编排(统一调用下面的取数脚本),
|
||||
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
|
||||
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
|
||||
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
|
||||
并把结果落盘到 <工程根>/data/<userId>.json
|
||||
|
||||
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
|
||||
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
|
||||
|
||||
取数 —— GetUser*(一个接口一个脚本)
|
||||
get_user_data.py GetUserDataApi
|
||||
get_user_extend.py GetUserExtendApi
|
||||
get_user_option.py GetUserOptionApi
|
||||
get_user_character.py GetUserCharacterApi
|
||||
get_user_ghost.py GetUserGhostApi
|
||||
get_user_map.py GetUserMapApi
|
||||
get_user_login_bonus.py GetUserLoginBonusApi
|
||||
get_user_rating.py GetUserRatingApi
|
||||
get_user_item.py GetUserItemApi
|
||||
get_user_music.py GetUserMusicApi
|
||||
get_user_course.py GetUserCourseApi
|
||||
get_user_charge.py GetUserChargeApi
|
||||
get_user_favorite.py GetUserFavoriteApi
|
||||
get_user_activity.py GetUserActivityApi
|
||||
get_user_mission.py GetUserMissionDataApi
|
||||
get_user_intimate.py GetUserIntimateApi
|
||||
get_user_shop_stock.py GetUserShopStockApi
|
||||
get_user_favorite_item.py GetUserFavoriteItemApi
|
||||
get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
|
||||
get_user_friend_bonus.py GetUserFriendBonusApi
|
||||
get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
|
||||
get_user_region.py GetUserRegionApi
|
||||
get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
|
||||
get_user_new_item.py GetUserNewItemListApi
|
||||
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*
|
||||
get_game_settings.py GetGameSettingApi
|
||||
get_game_charge.py GetGameChargeApi
|
||||
GetGameSettings.py GetGameSettingApi
|
||||
GetGameCharge.py GetGameChargeApi
|
||||
"""
|
||||
|
||||
from .log_setup import (
|
||||
from .LogSetup import (
|
||||
# 会话
|
||||
setup_logging,
|
||||
teardown_logging,
|
||||
@@ -107,8 +108,8 @@ from .log_setup import (
|
||||
# 1) 网络核心
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .maimai_net import (
|
||||
# 协议常量(原在 config.py,已下沉到 maimai_net.py)
|
||||
from .MaimaiNet import (
|
||||
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
|
||||
BASE_URL,
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
@@ -141,9 +142,9 @@ from .maimai_net import (
|
||||
# 2) 会话链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .chime import (
|
||||
from .Chime import (
|
||||
ParseQRCode,
|
||||
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py)
|
||||
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PORT,
|
||||
AIMEDB_PATH,
|
||||
@@ -153,7 +154,7 @@ from .chime import (
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
|
||||
from .get_game_settings import (
|
||||
from .GetGameSettings import (
|
||||
GetGameSettings,
|
||||
GameSetting,
|
||||
GameSettingRequestVO,
|
||||
@@ -162,21 +163,21 @@ from .get_game_settings import (
|
||||
is_ping_disabled,
|
||||
)
|
||||
|
||||
from .ping import Ping
|
||||
from .Ping import Ping
|
||||
|
||||
from .get_user_preview import (
|
||||
from .GetUserPreview import (
|
||||
GetUserPreview,
|
||||
UserPreviewRequestVO,
|
||||
UserPreviewResponseVO,
|
||||
)
|
||||
|
||||
from .userlogin import (
|
||||
from .UserLogin import (
|
||||
UserLogin,
|
||||
UserLoginRequestVO,
|
||||
UserLoginResponseVO,
|
||||
)
|
||||
|
||||
from .userlogout import (
|
||||
from .UserLogout import (
|
||||
UserLogout,
|
||||
LogoutType,
|
||||
DelayLog,
|
||||
@@ -193,7 +194,7 @@ from .userlogout import (
|
||||
# 3) 上传成绩
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .upsert_user_all import (
|
||||
from .UpsertUserAll import (
|
||||
UpsertUserAll,
|
||||
PlayData,
|
||||
build_play_from_config,
|
||||
@@ -236,6 +237,13 @@ from .upsert_user_all import (
|
||||
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,
|
||||
@@ -255,7 +263,7 @@ from .upsert_user_all import (
|
||||
USER_MISSION_DROP_KEYS,
|
||||
)
|
||||
|
||||
from .get_need_user_data import (
|
||||
from .GetNeedUserData import (
|
||||
GetNeedUserData,
|
||||
save_snapshot,
|
||||
load_snapshot,
|
||||
@@ -270,8 +278,24 @@ from .get_need_user_data import (
|
||||
list_saved_user_ids,
|
||||
)
|
||||
|
||||
from .upsert_user_chargelog import (
|
||||
# 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ★ 买票前置闸门: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,
|
||||
@@ -294,7 +318,7 @@ from .upsert_user_chargelog import (
|
||||
# 4) 取数公共底座
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .user_getter_common import (
|
||||
from .UserGetterCommon import (
|
||||
EmptyRequestVO as GetterEmptyRequestVO,
|
||||
UserIdRequestVO as GetterUserIdRequestVO,
|
||||
UserIdPageRequestVO,
|
||||
@@ -318,37 +342,37 @@ from .user_getter_common import (
|
||||
# 5) 取数脚本(一个接口一个脚本)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .get_user_data import GetUserData
|
||||
from .get_user_extend import GetUserExtend
|
||||
from .get_user_option import GetUserOption
|
||||
from .get_user_character import GetUserCharacter
|
||||
from .get_user_ghost import GetUserGhost
|
||||
from .get_user_map import GetUserMap, GetUserMapAll
|
||||
from .get_user_login_bonus import GetUserLoginBonus, GetUserLoginBonusAll
|
||||
from .get_user_rating import GetUserRating
|
||||
from .get_user_item import GetUserItem, GetUserItemAll
|
||||
from .get_user_music import GetUserMusicDetail, GetUserMusicDetailAll
|
||||
from .get_user_course import GetUserCourse, GetUserCourseAll
|
||||
from .get_user_charge import GetUserCharge
|
||||
from .get_user_favorite import GetUserFavorite, GetUserFavoriteAll
|
||||
from .get_user_activity import GetUserActivity
|
||||
from .get_user_mission import GetUserMissionData
|
||||
from .get_user_intimate import GetUserIntimate
|
||||
from .get_user_shop_stock import GetUserShopStock
|
||||
from .get_user_favorite_item import (
|
||||
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 .get_user_rival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
|
||||
from .get_user_friend_bonus import GetUserFriendBonus, extract_gift_points
|
||||
from .get_user_kaleidx_scope import GetUserKaleidxScope, GetGameKaleidxScope
|
||||
from .get_user_region import GetUserRegion
|
||||
from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
|
||||
from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO
|
||||
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 .get_game_charge import GetGameCharge, GameChargeRequestVO
|
||||
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
||||
@@ -369,6 +393,7 @@ _API_FUNCS = (
|
||||
"merge_extra_data",
|
||||
"verify_upsert_landing",
|
||||
"build_user_all",
|
||||
"build_unlock_items",
|
||||
# --- 购买功能票 ---
|
||||
"UpsertUserChargelog",
|
||||
"ticket_name",
|
||||
@@ -471,8 +496,16 @@ __all__ = [
|
||||
# ---- 上传成绩 ----
|
||||
"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",
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_user_charge.py —— 取用户票券(GetUserChargeApi)
|
||||
================================================================================
|
||||
|
||||
取玩家的票券持有情况(真机 3 条)。
|
||||
|
||||
对应接口:GetUserChargeApi
|
||||
请求体 :UserIdRequestVO
|
||||
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
|
||||
|
||||
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
|
||||
|
||||
调用示例:
|
||||
from api import GetUserCharge
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient
|
||||
from .user_getter_common 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))
|
||||
return _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||
+12
-25
@@ -8,11 +8,14 @@ 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/upsert_user_chargelog.py)得到
|
||||
4. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
|
||||
userChargelog.price(= ticket.creditNum)
|
||||
5. 闸门 → 发包
|
||||
|
||||
@@ -20,7 +23,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
用法
|
||||
--------------------------------------------------------------------------------
|
||||
python buy_ticket.py <二维码> # 预检:只看报文,不发包
|
||||
python buy_ticket.py <二维码> --list # 只列当前票券和可买的票
|
||||
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
|
||||
@@ -60,7 +63,6 @@ from api import (
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
GetUserCharge,
|
||||
GetGameCharge,
|
||||
ticket_name,
|
||||
GetNeedUserData,
|
||||
summarize_snapshot,
|
||||
@@ -99,7 +101,7 @@ def show_charges(user_id, label):
|
||||
|
||||
def parse_args(argv):
|
||||
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
|
||||
"debug": False, "skip_entry": False, "wait_before": 30.0}
|
||||
"debug": False, "skip_entry": False, "wait_before": 15.0}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
@@ -136,27 +138,6 @@ def main() -> int:
|
||||
setup_logging("buy_ticket")
|
||||
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||
try:
|
||||
# ---- ★★ 最早的前置检查:这张票当前**上架了吗** ----
|
||||
# ★ GetGameChargeApi 不需要登录(user_id=0),所以放在**扫码之前**,
|
||||
# 不满足就直接退出 —— 不浪费二维码,也不产生任何登录会话。
|
||||
# 依据 TicketManager.cs:137-150:真机也只买 GetGameChargeApi 列出来的票。
|
||||
shop = GetGameCharge().get("gameChargeList") or []
|
||||
avail = [int(g.get("chargeId")) for g in shop
|
||||
if isinstance(g, dict) and g.get("chargeId") is not None]
|
||||
log_info("当前可购买 chargeId:" + str(avail))
|
||||
for g in shop:
|
||||
log_info(" chargeId=" + str(g.get("chargeId"))
|
||||
+ " " + ticket_name(g.get("chargeId"))
|
||||
+ " money price=" + str(g.get("price"))
|
||||
+ " 有效期 " + str(g.get("startDate")) + " ~ " + str(g.get("endDate")))
|
||||
if opts["charge_id"] not in avail:
|
||||
log_error("chargeId=" + str(opts["charge_id"])
|
||||
+ "(" + ticket_name(opts["charge_id"])
|
||||
+ ")**不在当前可购买清单里** —— 连登录都不做,直接退出。")
|
||||
log_error(" 原因:服务端对未上架的票会 returnCode=0(实测过)。")
|
||||
log_error(" 想买请用清单里的 chargeId:" + str(avail))
|
||||
return 4
|
||||
|
||||
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
|
||||
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
|
||||
# 不跑的话 number 恒为 "0"(真机是 75)。
|
||||
@@ -187,6 +168,10 @@ def main() -> int:
|
||||
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
|
||||
held_before = show_charges(user_id, "购买前")
|
||||
|
||||
if opts["list"]:
|
||||
log_info("[--list] 只查询票券状态,不购买。")
|
||||
return 0
|
||||
|
||||
aborted = False
|
||||
if held_before:
|
||||
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
|
||||
@@ -199,6 +184,7 @@ def main() -> int:
|
||||
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"])
|
||||
@@ -211,6 +197,7 @@ def main() -> int:
|
||||
dry_run=not opts["send"],
|
||||
confirm=opts["send"],
|
||||
guard_no_stock=True,
|
||||
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
|
||||
place_id=PLACE_ID, region_id=REGION_ID,
|
||||
)
|
||||
|
||||
|
||||
+5
-5
@@ -31,7 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
|
||||
# 一、网络与加密常量
|
||||
# =============================================================================
|
||||
|
||||
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py
|
||||
# ★ 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)
|
||||
@@ -109,7 +109,7 @@ AUTHV1_WRITE_CONFIG = False
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs/舞萌DX_扫码登录协议技术规范.md
|
||||
|
||||
# ★ aimedb / QR 常量已下沉到 api/chime.py
|
||||
# ★ aimedb / QR 常量已下沉到 api/Chime.py
|
||||
# =============================================================================
|
||||
# 三之二、★ 功能票(Ticket)主数据
|
||||
# =============================================================================
|
||||
@@ -128,7 +128,7 @@ AUTHV1_WRITE_CONFIG = False
|
||||
# 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/upsert_user_chargelog.py
|
||||
# 已下沉到 api/UpsertUserChargelog.py
|
||||
|
||||
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
|
||||
UPSERT_CHARGELOG_CONFIRM = False
|
||||
@@ -227,7 +227,7 @@ PLAY_IS_FREEDOM_MODE = False
|
||||
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
|
||||
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
|
||||
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py)
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
|
||||
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
|
||||
@@ -256,7 +256,7 @@ PLAY_IS_FREEDOM_MODE = False
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
#
|
||||
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
|
||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py
|
||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
|
||||
# --- 用户总游玩次数增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
+119
-17
@@ -11,19 +11,27 @@ reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克
|
||||
↓ 节点数据
|
||||
┌─ 目标账号 B ───────────────────────────────────────────┐
|
||||
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
||||
│ 循环:UpsertUserAll(每包最多 4 首曲目) │
|
||||
│ 然后 UserLogin(isContinue=true) 续关,拿新 loginId│
|
||||
│ 收尾:UserLogout │
|
||||
│ 循环: │
|
||||
│ 等待 -> UpsertUserAll(一包曲目) │
|
||||
│ -> UserLogout(type=1) │
|
||||
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
|
||||
│ 收尾:最后一包登出后结束(不再续关) │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 为什么一次最多传 4 首
|
||||
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
|
||||
--------------------------------------------------------------------------------
|
||||
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
||||
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
||||
|
||||
本脚本把源账号的 userMusicDetailList 按 4 条一组切分,一组 = 一局。
|
||||
★ 每局都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
||||
★ 但实测:**每包恰好 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 但不落库)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -41,6 +49,11 @@ reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克
|
||||
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
||||
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
||||
|
||||
★ 登出必须用 **type=1(LogoutType.Logout)**。
|
||||
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
|
||||
{"userId":...,"type":1,"delayLog":{...}}
|
||||
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ playlog 从哪来
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -75,7 +88,8 @@ reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克
|
||||
|
||||
# 常用开关
|
||||
--limit N 只迁前 N 首(试跑用)
|
||||
--per-packet N 每包曲目数(默认 4,★ 不要超过 4)
|
||||
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
|
||||
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
|
||||
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
||||
--no-continue 不续关(只发一包,调试用)
|
||||
|
||||
@@ -87,6 +101,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from api import (
|
||||
setup_logging,
|
||||
@@ -120,6 +135,60 @@ 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")
|
||||
|
||||
@@ -138,7 +207,8 @@ 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}
|
||||
"clone_identity": False, "no_continue": False,
|
||||
"wait": 5.0}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
@@ -152,9 +222,12 @@ def parse_args(argv):
|
||||
opts["clone_identity"] = True
|
||||
elif a == "--no-continue":
|
||||
opts["no_continue"] = True
|
||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit", "--per-packet") and i + 1 < len(argv):
|
||||
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
|
||||
"--per-packet", "--wait") and i + 1 < len(argv):
|
||||
key = a[2:].replace("-", "_")
|
||||
opts[key] = int(argv[i + 1]) if key in ("limit", "per_packet") else argv[i + 1]
|
||||
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
|
||||
@@ -267,8 +340,9 @@ def main() -> int:
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
if opts["per_packet"] > MAX_TRACKS_PER_CREDIT:
|
||||
print("--per-packet 不能超过 " + str(MAX_TRACKS_PER_CREDIT) + "(真机一局最多 4 首)")
|
||||
if opts["per_packet"] not in ALLOWED_PER_PACKET:
|
||||
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
|
||||
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
|
||||
return 1
|
||||
|
||||
setup_logging("reincarnate")
|
||||
@@ -308,13 +382,20 @@ def main() -> int:
|
||||
|
||||
# ---------------- 3. 分批 ----------------
|
||||
per = int(opts["per_packet"])
|
||||
batches = [musics[i:i + per] for i in range(0, len(musics), per)]
|
||||
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))
|
||||
+ " 包(每包 " + str(per) + " 首)")
|
||||
+ " 包,每包 " + "/".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) + " 包:" + ", ".join(
|
||||
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
|
||||
@@ -325,12 +406,23 @@ def main() -> int:
|
||||
+ " 个将在第 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)))
|
||||
@@ -370,7 +462,17 @@ def main() -> int:
|
||||
)
|
||||
sent += 1
|
||||
|
||||
# ---- ★ 续关登录:拿下一局的 loginId ----
|
||||
# ---- ★ 登出(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 包)")
|
||||
@@ -378,7 +480,7 @@ def main() -> int:
|
||||
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)
|
||||
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||
|
||||
if not opts["send"]:
|
||||
log_info("")
|
||||
|
||||
+183
-3
@@ -27,6 +27,8 @@ import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import api
|
||||
import unlockmusic
|
||||
import upsert
|
||||
from api import (
|
||||
BITS_TO_ARRAY,
|
||||
JUDGE_BOXES,
|
||||
@@ -38,6 +40,7 @@ from api import (
|
||||
build_delta,
|
||||
build_favorite_delta,
|
||||
build_play_from_config,
|
||||
build_unlock_items,
|
||||
build_user_all,
|
||||
build_user_game_playlog,
|
||||
build_user_playlog,
|
||||
@@ -49,6 +52,13 @@ from api import (
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
has_fresh_read,
|
||||
held_charges,
|
||||
last_read_age,
|
||||
note_charge_purchase,
|
||||
note_user_charge_read,
|
||||
purchased_charge_ids,
|
||||
reset_charge_guard,
|
||||
ticket_name,
|
||||
ticket_price,
|
||||
calc_valid_date,
|
||||
@@ -70,13 +80,13 @@ from api import (
|
||||
login_date_time_now,
|
||||
local_utc_offset_seconds,
|
||||
)
|
||||
from api.upsert_user_all import (
|
||||
from api.UpsertUserAll import (
|
||||
ConfirmRequiredError,
|
||||
PatchError,
|
||||
UpsertUserAll,
|
||||
UserAllRequestVO,
|
||||
)
|
||||
from api.upsert_user_chargelog import _now_string
|
||||
from api.UpsertUserChargelog import _now_string
|
||||
|
||||
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
|
||||
from api import (
|
||||
@@ -351,7 +361,7 @@ fav_server_raw.append({"itemKind": None, "itemIdList": []})
|
||||
_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
|
||||
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw)
|
||||
|
||||
# 归一化兜底:基线被 get_user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
|
||||
# 归一化兜底:基线被 GetUserFavorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
|
||||
# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
|
||||
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
|
||||
_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
|
||||
@@ -1102,6 +1112,149 @@ except ChargelogConfirmError:
|
||||
check("买票未开闸门抛 ChargelogConfirmError(未发包)", True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 13.7 歌曲解锁(解禁)
|
||||
# =============================================================================
|
||||
|
||||
section("13.7 歌曲解锁(解禁)")
|
||||
|
||||
# 依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
|
||||
# kind5 = MusicUnlockList(歌曲本体)
|
||||
# kind6 = MusicUnlockMasterList(Master)
|
||||
# kind7 = MusicUnlockReMasterList(Re:Master)
|
||||
# 依据 source/Assembly-CSharp/Manager/NotesListManager.cs:415/455
|
||||
# `if (id < 10000) return true;` -> 标准谱 Master/Re:Master 天生解锁
|
||||
|
||||
_unl_std, _unl_std_notes = build_unlock_items([1085])
|
||||
check("标准谱(<10000)只写 kind5,不写 kind6/7",
|
||||
[it["itemKind"] for it in _unl_std] == [5], str([it["itemKind"] for it in _unl_std]))
|
||||
check("解锁条目形态 = {itemKind,itemId,stock,isValid}",
|
||||
_unl_std[0] == {"itemKind": 5, "itemId": 1085, "stock": 1, "isValid": True},
|
||||
str(_unl_std[0]))
|
||||
check("标准谱给出「天生解锁」说明",
|
||||
any("天生解锁" in n for n in _unl_std_notes), str(_unl_std_notes))
|
||||
|
||||
_unl_dx, _ = build_unlock_items([11414])
|
||||
check("DX 谱写 kind5 + kind6 + kind7",
|
||||
[it["itemKind"] for it in _unl_dx] == [5, 6, 7], str([it["itemKind"] for it in _unl_dx]))
|
||||
|
||||
_unl_m, _ = build_unlock_items([11414], master=False)
|
||||
check("master=False 不写 kind6", [it["itemKind"] for it in _unl_m] == [5, 7],
|
||||
str([it["itemKind"] for it in _unl_m]))
|
||||
_unl_r, _ = build_unlock_items([11414], remaster=False)
|
||||
check("remaster=False 不写 kind7", [it["itemKind"] for it in _unl_r] == [5, 6],
|
||||
str([it["itemKind"] for it in _unl_r]))
|
||||
|
||||
_unl_dup, _ = build_unlock_items([11414], existing=[{"itemKind": 5, "itemId": 11414}])
|
||||
check("已存在的条目自动跳过(不重复插入)",
|
||||
[it["itemKind"] for it in _unl_dup] == [6, 7], str([it["itemKind"] for it in _unl_dup]))
|
||||
_unl_all, _ = build_unlock_items(
|
||||
[11414], existing=[{"itemKind": k, "itemId": 11414} for k in (5, 6, 7)])
|
||||
check("全都有时返回空列表", _unl_all == [], str(_unl_all))
|
||||
check("musicId 重复只写一次", len(build_unlock_items([1085, 1085])[0]) == 1)
|
||||
|
||||
_ua_unl = build_user_all(baseline, login_date_time=1791159576, playlog_id=1,
|
||||
unlock_music_ids=[11414])
|
||||
check("build_user_all 把解锁条目追加进 userItemList",
|
||||
[it["itemKind"] for it in _ua_unl["userItemList"]] == [5, 6, 7],
|
||||
str(_ua_unl["userItemList"]))
|
||||
check("解锁条目的位串全是 1(新增)", _ua_unl["isNewItemList"] == "111",
|
||||
_ua_unl["isNewItemList"])
|
||||
check("位串长度与 userItemList 条数一致",
|
||||
len(_ua_unl["isNewItemList"]) == len(_ua_unl["userItemList"]))
|
||||
check("不给 unlock_music_ids 时不产生 userItem 条目",
|
||||
build_user_all(baseline, login_date_time=1791159576,
|
||||
playlog_id=1)["userItemList"] == [])
|
||||
|
||||
_ua_unl3 = build_user_all(
|
||||
baseline, login_date_time=1791159576, playlog_id=1,
|
||||
extra_data={"userItemList": [{"itemKind": 5, "itemId": 1085,
|
||||
"stock": 1, "isValid": True}],
|
||||
"isNewItemList": "1"},
|
||||
unlock_music_ids=[11414])
|
||||
check("extra_data 给了 userItemList 时解锁条目追加在其后",
|
||||
[it["itemId"] for it in _ua_unl3["userItemList"]] == [1085, 11414, 11414, 11414],
|
||||
str([it["itemId"] for it in _ua_unl3["userItemList"]]))
|
||||
check("已有条目的位串保留、新条目补 1", _ua_unl3["isNewItemList"] == "1111",
|
||||
_ua_unl3["isNewItemList"])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 13.8 unlockmusic.py 的参数解析(主脚本复用 upsert.run)
|
||||
# =============================================================================
|
||||
|
||||
section("13.8 unlockmusic.py 参数解析")
|
||||
|
||||
_uopts = unlockmusic.parse_args(["QRCODE", "--music", "11414", "--send"])
|
||||
check("--music 解析成 int 列表", _uopts["unlock_music"] == [11414],
|
||||
str(_uopts["unlock_music"]))
|
||||
check("二维码取第一个非 -- 参数", _uopts["qr"] == "QRCODE", _uopts["qr"])
|
||||
check("--send 置位", _uopts["send"] is True)
|
||||
|
||||
_uopts = unlockmusic.parse_args(["QR", "--music", "1085,11414", "--music", "7"])
|
||||
check("逗号(含中文逗号)+ 可重复都认",
|
||||
_uopts["unlock_music"] == [1085, 11414, 7], str(_uopts["unlock_music"]))
|
||||
|
||||
_uopts = unlockmusic.parse_args(["QR", "--unlock-music", "11414",
|
||||
"--no-unlock-master", "--no-unlock-remaster"])
|
||||
check("--unlock-music 是 --music 的别名", _uopts["unlock_music"] == [11414])
|
||||
check("两个难度开关默认 True、可关掉",
|
||||
_uopts["unlock_master"] is False and _uopts["unlock_remaster"] is False)
|
||||
|
||||
_uopts = unlockmusic.parse_args(["QR", "--music", "abc"])
|
||||
check("非法 musicId 记进 unlock_bad", _uopts.get("unlock_bad") == "abc")
|
||||
|
||||
check("upsert.parse_args 不再认识 --unlock-music",
|
||||
"unlock_music" not in upsert.parse_args(["QR", "--unlock-music", "11414"]))
|
||||
check("upsert.run 存在且可被别的脚本复用", callable(getattr(upsert, "run", None)))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 13.9 买票前置闸门(必须先跑 GetUserCharge)
|
||||
# =============================================================================
|
||||
|
||||
section("13.9 买票前置闸门(必须先跑 GetUserCharge)")
|
||||
|
||||
reset_charge_guard()
|
||||
check("没查过票时 has_fresh_read 为 False", has_fresh_read(55555) is False)
|
||||
check("没查过票时 last_read_age 为 None", last_read_age(55555) is None)
|
||||
|
||||
note_user_charge_read(55555, [{"chargeId": 2, "stock": 0},
|
||||
{"chargeId": 5, "stock": 1}])
|
||||
check("查过之后 has_fresh_read 为 True", has_fresh_read(55555) is True)
|
||||
check("held_charges 只挑 stock>0 的",
|
||||
[c["chargeId"] for c in held_charges(55555)] == [5], str(held_charges(55555)))
|
||||
|
||||
try:
|
||||
UpsertUserChargelog(66666, 2, 1791159576, user_data={},
|
||||
guard_purchasable=False, dry_run=True)
|
||||
check("没查票就买票应抛 ChargelogGuardError", False, "未抛异常")
|
||||
except ChargelogGuardError as exc:
|
||||
check("没查票就买票抛 ChargelogGuardError(未发包)", True)
|
||||
check("报错信息里点明要先调 GetUserCharge",
|
||||
"GetUserCharge" in str(exc), str(exc)[:120])
|
||||
|
||||
check("显式传 charge_list 视为已查票(可通过)",
|
||||
UpsertUserChargelog(66666, 2, 1791159576, charge_list=[], user_data={},
|
||||
guard_purchasable=False,
|
||||
dry_run=True).userCharge.chargeId == 2)
|
||||
|
||||
note_charge_purchase(77777, 2)
|
||||
check("purchased_charge_ids 记录已买", purchased_charge_ids(77777) == [2])
|
||||
note_user_charge_read(77777, [])
|
||||
try:
|
||||
UpsertUserChargelog(77777, 2, 1791159576, charge_list=[], user_data={},
|
||||
guard_purchasable=False, dry_run=True)
|
||||
check("同进程重复买票应被拦下", False, "未抛异常")
|
||||
except ChargelogGuardError as exc:
|
||||
check("同进程重复买票抛 ChargelogGuardError", True)
|
||||
check("报错信息点明已经买过", "已经买过" in str(exc), str(exc)[:120])
|
||||
|
||||
reset_charge_guard()
|
||||
check("reset 之后记忆清空",
|
||||
has_fresh_read(55555) is False and purchased_charge_ids(77777) == [])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 14. 副作用闸门
|
||||
# =============================================================================
|
||||
@@ -1165,6 +1318,33 @@ check("会话链路 6 个函数都在",
|
||||
"UserLogin", "UserLogout")))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 19. reincarnate 分批器(★ 每包恰好 2 首会被服务端拒绝)
|
||||
# =============================================================================
|
||||
|
||||
section("19. reincarnate 分批器")
|
||||
|
||||
from reincarnate import split_batches as _split # noqa: E402
|
||||
|
||||
for _t, _p, _want in [(10, 4, [4, 3, 3]), (6, 4, [3, 3]), (2, 4, [1, 1]),
|
||||
(5, 3, [3, 1, 1]), (8, 3, [3, 3, 1, 1]), (5, 4, [4, 1]),
|
||||
(4, 4, [4]), (1, 4, [1]), (7, 4, [4, 3])]:
|
||||
_got = _split(_t, _p)
|
||||
check("分批 " + str(_t) + " 首 per=" + str(_p) + " -> " + str(_want),
|
||||
_got == _want, str(_got))
|
||||
|
||||
_bad = []
|
||||
for _t in range(1, 300):
|
||||
for _p in (1, 3, 4):
|
||||
_g = _split(_t, _p)
|
||||
if sum(_g) != _t or 2 in _g or any(x > _p or x < 1 for x in _g):
|
||||
_bad.append((_t, _p, _g))
|
||||
check("1..299 首 × per∈{1,3,4} 都不产生 2 首包", not _bad, str(_bad[:3]))
|
||||
|
||||
check("--per-packet 允许值 = (1, 3, 4)",
|
||||
__import__("reincarnate").ALLOWED_PER_PACKET == (1, 3, 4))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 汇总
|
||||
# =============================================================================
|
||||
|
||||
@@ -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)
|
||||
+138
@@ -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())
|
||||
@@ -29,9 +29,12 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
|
||||
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
|
||||
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
|
||||
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
|
||||
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 60 秒冷却
|
||||
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"}'
|
||||
@@ -83,6 +86,17 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
|
||||
新增条目请显式传 "1","0" 表示存量更新。
|
||||
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
★ 这个文件同时是一个可复用的模块
|
||||
--------------------------------------------------------------------------------
|
||||
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
|
||||
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
|
||||
|
||||
import upsert
|
||||
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
|
||||
|
||||
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
|
||||
|
||||
── 在代码里直接调 api 的等价写法 ──
|
||||
|
||||
UpsertUserAll(
|
||||
@@ -146,19 +160,24 @@ def parse_args(argv):
|
||||
return opts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
opts = parse_args(sys.argv[1:])
|
||||
if not opts["qr"]:
|
||||
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["debug"]:
|
||||
if opts.get("debug"):
|
||||
import config as _cfg
|
||||
_cfg.LOG_LEVEL = "debug"
|
||||
|
||||
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
|
||||
setup_logging("upsert")
|
||||
setup_logging(log_name)
|
||||
|
||||
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
|
||||
extra = None
|
||||
@@ -186,6 +205,13 @@ def main() -> int:
|
||||
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:
|
||||
@@ -210,6 +236,9 @@ def main() -> int:
|
||||
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"],
|
||||
@@ -235,5 +264,9 @@ def main() -> int:
|
||||
teardown_logging()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run(parse_args(sys.argv[1:]), log_name="upsert")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ from api import (
|
||||
ConfirmRequiredError,
|
||||
ticket_name,
|
||||
Ping,
|
||||
# ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py
|
||||
# ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
|
||||
TICKET_CREDIT_NUM,
|
||||
)
|
||||
from config import (
|
||||
|
||||
Reference in New Issue
Block a user