Compare commits

...
4 Commits
Author SHA1 Message Date
Txiaochen 61bf36661d docs: 仓库改名 Astral 后同步链接;chart_achievement README 补充谱面查询站点 2026-10-17 17:49:11 +08:00
Txiaochen f6e84cf297 chart_achievement: 正向支持按音符类别给判定数(绝赞单列),算出确切达成率 2026-10-17 17:43:00 +08:00
Txiaochen 25ac522391 v0 plus附加功能
- 新增 unlockmusic.py:解锁(解禁)歌曲 + 解禁 Master / Re:Master 难度,整个流程复用 upsert.py 的 run()
- 买票前置闸门 api/ChargeGuard.py:必须先跑过 GetUserCharge,且同进程只允许买一次
- buy_ticket.py 票源改为 GetUserCharge,不再调用 GetGameCharge
- UpsertUserAll 与买票的冷却统一为 15 秒
- api/ 文件名统一 PascalCase(文件名 = 函数名)
- 项目改名 Astral,README 加 Logo、鸣谢与开发者说明
- 自检 284 项
2026-10-17 17:39:55 +08:00
Txiaochen b1d7e33c7b chart_achievement: --level 改为谱面难度(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER),反向示例补上难度 2026-10-07 23:37:39 +08:00
52 changed files with 1579 additions and 425 deletions
+111 -41
View File
@@ -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"> <p align="center">
<b>舞萌DX 的游戏助手</b><br> <b>舞萌DX 的游戏助手</b><br>
@@ -11,28 +15,29 @@
## 这是什么 ## 这是什么
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。 Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍: 它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
扫码登录 → 取数据 → 上传成绩 → 登出。 扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证, > 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。 > 每个关键常量在代码注释里都标了**源码行号**。
--- ---
## 当前状态:v0 ## 当前状态:v0 plus 附加功能
| 脚本 | 状态 | | 脚本 | 状态 |
|---|---| |---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 | | **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 | | **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 | | **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) | | **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) | | **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** | | `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),见文末[文档索引](#文档索引)。 **文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
--- ---
@@ -59,35 +64,44 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
## 项目框架 ## 项目框架
``` ```
WaHappy/ Astral/
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建 ├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板 ├── config.py.example 配置模板
├── icon/ Logo
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出 ├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
├── buy_ticket.py 主脚本:购买功能票 ├── buy_ticket.py 主脚本:购买功能票
├── use_ticket.py 主脚本:消耗当前持有的功能票 ├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生) ├── reincarnate.py 主脚本:账号数据迁移(转生)
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包 ├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
├── selftest.py 零网络自检(249 项,判据 = 源码 + 真机日志) ├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
│ │
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本) ├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪 │ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie) │ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── log_setup.py 日志(INFO / DEBUG 双模式) │ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分) │ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
│ ├── get_need_user_data.py 结算快照编排 + 落盘 data/<userId>.json │ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/<userId>.json
│ └── ... 40+ 个接口脚本 │ └── ... 40+ 个接口脚本
│ │
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库 ├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库 ├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库 └── logs/ 日志输出(latest.log + 日期.log)★ 不入库
└── source/ 反编译源码 ★ 不入库,需自备
``` ```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。 **设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。 主脚本只负责编排,日志和协议细节都在 `api/` 内部。
**命名约定**:服务端 API 名里带 `Get` 的,**脚本名与函数名一律带 `get` / `Get`** **命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
(不要模棱两可)。所以取数脚本全是 `get_*.py`,函数是 `GetXxx`。
| 服务端接口 | 脚本 | 函数 |
|---|---|---|
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
### 分支说明 ### 分支说明
@@ -97,7 +111,10 @@ WaHappy/
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 | | **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。 两个分支**没有共同的提交历史**,各自独立。
`source/` 是反编译源码,**需要自备**。
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
> 只是用工具的话**完全不需要**这个目录。
--- ---
@@ -136,7 +153,7 @@ copy config.py.example config.py # Windows
### 3. 跑自检 ### 3. 跑自检
```bash ```bash
python selftest.py # 零网络自检,应该 249 项全绿(不依赖你的 config) python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
``` ```
### 主脚本一览 ### 主脚本一览
@@ -144,6 +161,7 @@ python selftest.py # 零网络自检,应该 249 项全绿(不依赖
| 脚本 | 干什么 | 副作用 | 需要二维码 | | 脚本 | 干什么 | 副作用 | 需要二维码 |
|---|---|---|---| |---|---|---|---|
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ | | `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ | | `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ | | `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) | | `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
@@ -168,8 +186,9 @@ python upsert.py "<二维码>" --send
# 常用开关 # 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文 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 upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
``` ```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。** **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
@@ -199,7 +218,7 @@ python upsert.py "<二维码>" --send --extra-data extra.json
| `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT | | `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT |
| `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 | | `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 |
主键见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS` 主键见 `api/UpsertUserAll.py` 的 `DIFF_PRIMARY_KEYS`
(如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。 (如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。
> ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。 > ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。
@@ -210,10 +229,52 @@ python upsert.py "<二维码>" --send --extra-data extra.json
--- ---
## unlockmusic.py —— 解锁(解禁)歌曲
```bash
python unlockmusic.py "<二维码>" --music 11414 # 预检,不发包
python unlockmusic.py "<二维码>" --music 11414 --send # ★ 真实解锁
python unlockmusic.py "<二维码>" --music 1085,11414 --send # 多首,逗号分隔
python unlockmusic.py "<二维码>" --music 11414 --send --no-unlock-remaster
```
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
扫码 → 预览 → 登录 → `UpsertUserAll` → 落库回查 → 登出,一模一样。
唯一多出来的东西是「要解锁哪些歌」这个入参。
**解锁也不是单独的接口** —— 它是往 `userItemList` 里补几条 `UserItem`:
| itemKind | 解锁什么 |
|---|---|
| `5` | 歌曲本体(`MusicUnlockList`) |
| `6` | Master 难度(`MusicUnlockMasterList`) |
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
条目形如 `{"itemKind":5,"itemId":1085,"stock":1,"isValid":true}`。
- **标准谱(`musicId < 10000`)的 Master / Re:Master 天生解锁**,只写 `kind5`;
只有 DX 谱(`>= 10000`)才需要写 `kind6` / `kind7`。
- 服务器上**已存在的条目会自动跳过** —— 重复插入会让服务端直接 HTTP 500。
- Re:Master 还受活动开关约束,那是机台侧的事,脚本改不了;写了也可能进游戏仍是灰的。
| 参数 | 说明 |
|---|---|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
| `--send` | 真正发包(不加就是预检,零副作用) |
| `--no-unlock-master` | 不解禁 Master |
| `--no-unlock-remaster` | 不解禁 Re:Master |
| `--cooldown N` / `--no-verify` / `--debug` | 和 `upsert.py` 完全一样 |
> 依据反编译源码 `VOExtensions.cs:981-1008`(四个解锁列表并进 `userItemList`)
> 与 `NotesListManager.cs:415/455`(`if (id < 10000) return true;`)。
> 这部分**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
---
## buy_ticket.py —— 购买功能票 ## buy_ticket.py —— 购买功能票
```bash ```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 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买 python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
``` ```
@@ -228,10 +289,17 @@ python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
### 它做的安全检查 ### 它做的安全检查
1. 买之前查 `GetGameChargeApi` —— 目标票不在可购买清单里就**连登录都不做**直接退出 1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
2. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险) 2. 买票前 **15 秒等待**(贴近真机节奏)
3. 跑后再查一次确认状态 3. 跑后再查一次确认状态
> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
> (那是机台本地的上架清单,跟账号状态无关)。
>
> `UpsertUserChargelog` 内部还有两道硬闸门:
> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
--- ---
## use_ticket.py —— 消耗当前持有的功能票 ## use_ticket.py —— 消耗当前持有的功能票
@@ -248,7 +316,7 @@ python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买; > **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
> 想再买一张就先用这个把它用掉。 > 想再买一张就先用这个把它用掉。
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/功能票使用说明.md)。 详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md)。
--- ---
@@ -284,7 +352,7 @@ python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json -
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。 > ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/账号转生说明.md)。 完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md)。
--- ---
@@ -357,7 +425,7 @@ if __name__ == "__main__":
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100` 2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True` 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)。
--- ---
@@ -372,7 +440,8 @@ if __name__ == "__main__":
- ✅ 账号数据快照落盘(`data/<userId>.json`) - ✅ 账号数据快照落盘(`data/<userId>.json`)
- ✅ 账号数据迁移(转生) - ✅ 账号数据迁移(转生)
- ✅ 查 / 下 `.app` / `.opt` 更新包 - ✅ 查 / 下 `.app` / `.opt` 更新包
- ✅ 零网络自检 249 项 - ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
- ✅ 零网络自检 284 项
- ✅ MelonLoader 抓包 Mod(`gamehook/`) - ✅ MelonLoader 抓包 Mod(`gamehook/`)
**将来可能会加** **将来可能会加**
@@ -386,24 +455,24 @@ if __name__ == "__main__":
## 文档索引 ## 文档索引
全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs): 全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs):
| 文档 | 内容 | | 文档 | 内容 |
|---|---| |---|---|
| [项目总览](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 | | [项目总览](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 |
| [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 | | [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 |
| [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 | | [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 |
| [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 | | [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 |
| [功能票使用说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 | | [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 |
| [账号转生说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 | | [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 |
| [netdelivery说明](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 | | [netdelivery说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 |
| [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 | | [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 |
| [购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 | | [购买功能票流程](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 |
| [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 | | [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 |
| [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 | | [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 |
| [实跑验证记录](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 | | [实跑验证记录](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 |
| [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 | | [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 |
| [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 | | [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 |
--- ---
@@ -412,6 +481,7 @@ if __name__ == "__main__":
- **志成🍥** —— 提供脱壳 DLL - **志成🍥** —— 提供脱壳 DLL
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型 - [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写 - **DeepSeek V4.1 Flash** —— 主要代码编写
- **GPT-Image-2** —— Logo 设计
- [**NVIDIA NIM**](https://build.nvidia.com/models)、 - [**NVIDIA NIM**](https://build.nvidia.com/models)、
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、 [**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型 [**Kilo**](https://kilo.ai/)—— 提供 AI 模型
+150
View File
@@ -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)
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
chime.py —— 扫码登录(二维码 -> userId + token) Chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================ ================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。 功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
@@ -76,7 +76,7 @@ QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB" QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB" 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 + 技术规范) # 二维码格式常量(CCommQRCode.cs + 技术规范)
@@ -288,7 +288,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("chime_test") setup_logging("chime_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass @dataclass
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning from .LogSetup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
# ============================================================================= # =============================================================================
@@ -154,7 +154,7 @@ def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
# ============================================================================= # =============================================================================
if __name__ == "__main__": if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("gamesettings_test") setup_logging("gamesettings_test")
@@ -166,7 +166,7 @@ if __name__ == "__main__":
print(" " + name.ljust(34) + "= " + str(getattr(gs, name))) print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0 return 0
except Exception as exc: except Exception as exc:
from .log_setup import log_error from .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc)) log_error("获取游戏设置失败:" + str(exc))
return 1 return 1
finally: finally:
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_need_user_data.py —— 结算快照编排(统一调用各取数脚本) GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================ ================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。 功能:一键抓取 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 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 .LogSetup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本) # 各取数脚本(一个接口一个脚本)
from .get_user_data import GetUserData from .GetUserData import GetUserData
from .get_user_extend import GetUserExtend from .GetUserExtend import GetUserExtend
from .get_user_option import GetUserOption from .GetUserOption import GetUserOption
from .get_user_character import GetUserCharacter from .GetUserCharacter import GetUserCharacter
from .get_user_ghost import GetUserGhost from .GetUserGhost import GetUserGhost
from .get_user_map import GetUserMapAll from .GetUserMap import GetUserMapAll
from .get_user_login_bonus import GetUserLoginBonusAll from .GetUserLoginBonus import GetUserLoginBonusAll
from .get_user_rating import GetUserRating from .GetUserRating import GetUserRating
from .get_user_item import GetUserItemAll from .GetUserItem import GetUserItemAll
from .get_user_music import GetUserMusicDetailAll from .GetUserMusic import GetUserMusicDetailAll
from .get_user_course import GetUserCourseAll from .GetUserCourse import GetUserCourseAll
from .get_user_charge import GetUserCharge from .GetUserCharge import GetUserCharge
from .get_user_favorite import GetUserFavoriteAll from .GetUserFavorite import GetUserFavoriteAll
from .get_user_activity import GetUserActivity from .GetUserActivity import GetUserActivity
from .get_user_mission import GetUserMissionData from .GetUserMission import GetUserMissionData
from .get_user_intimate import GetUserIntimate from .GetUserIntimate import GetUserIntimate
from .get_user_shop_stock import GetUserShopStock from .GetUserShopStock import GetUserShopStock
from .get_user_favorite_item import GetUserFavoriteItemAll from .GetUserFavoriteItem import GetUserFavoriteItemAll
from .get_user_kaleidx_scope import GetUserKaleidxScope from .GetUserKaleidxScope import GetUserKaleidxScope
# ============================================================================= # =============================================================================
@@ -372,7 +372,7 @@ def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
from .log_setup import log_error, setup_logging, teardown_logging from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("get_need_user_data_test") setup_logging("get_need_user_data_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_activity.py —— 取用户活动记录(GetUserActivityApi) GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================ ================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。 取玩家的活动/成就记录(playList 与 musicList 两个列表)。
@@ -25,9 +25,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserActivity( def GetUserActivity(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_character.py —— 取用户角色列表(GetUserCharacterApi) GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================ ================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。 取玩家所有角色的等级与觉醒度(真机 312 条)。
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharacter( def GetUserCharacter(
+63
View File
@@ -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 -*- # -*- coding: utf-8 -*-
""" """
get_user_course.py —— 取用户课题模式成绩(GetUserCourseApi) GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================ ================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。 取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
@@ -21,9 +21,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserCourseRequestVO, get_all_pages from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def GetUserCourse( def GetUserCourse(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_data.py —— 取用户全量数据(GetUserDataApi) GetUserData.py —— 取用户全量数据(GetUserDataApi)
================================================================================ ================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。 取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
@@ -29,9 +29,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserData( def GetUserData(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserExtend( def GetUserExtend(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning from .LogSetup import log_info, log_warning
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def GetUserFavorite( def GetUserFavorite(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi) GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================ ================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。 取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteItemRequestVO, get_all_pages from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值 #: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲 FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi) GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================ ================================================================================
取玩家本次可领取的好友里程(getMiles)。 取玩家本次可领取的好友里程(getMiles)。
@@ -21,9 +21,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning from .LogSetup import log_info, log_warning
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserFriendBonus( def GetUserFriendBonus(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_ghost.py —— 取用户幽灵数据(GetUserGhostApi) GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================ ================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。 取玩家的「幽灵」对战记录(真机 15 条)。
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserGhost( def GetUserGhost(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi) GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================ ================================================================================
取玩家与各搭档的亲密度(真机 2 条)。 取玩家与各搭档的亲密度(真机 2 条)。
@@ -20,9 +20,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserIntimate( def GetUserIntimate(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_item.py —— 取用户道具(GetUserItemApi) GetUserItem.py —— 取用户道具(GetUserItemApi)
================================================================================ ================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。 取玩家各类道具的库存(真机按 kind 分 12 次请求)。
@@ -38,9 +38,9 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning from .LogSetup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def GetUserItem( def GetUserItem(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 取空) ★★ 重要区分(曾导致 userData 取空)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。 GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 get_user_data.py)。 玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的 真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId, 两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
@@ -33,9 +33,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def GetUserKaleidxScope( def GetUserKaleidxScope(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi) GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================ ================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。 取玩家的登录奖励领取进度(真机 38 条,分两页)。
@@ -22,9 +22,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserLoginBonusRequestVO, get_all_pages from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def GetUserLoginBonus( def GetUserLoginBonus(
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_map.py —— 取用户地图进度(GetUserMapApi) GetUserMap.py —— 取用户地图进度(GetUserMapApi)
================================================================================ ================================================================================
取玩家各地图的推进距离(真机 104 条)。 取玩家各地图的推进距离(真机 104 条)。
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdPageRequestVO, get_all_pages from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def GetUserMap( def GetUserMap(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi) GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================ ================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。 取玩家的任务进度与周常数据(真机 6 条任务)。
@@ -25,9 +25,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserMissionData( def GetUserMissionData(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_music.py —— 取用户曲目成绩(GetUserMusicApi) GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================ ================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。 取玩家全部曲目的历史成绩归档(真机 739 组)。
@@ -27,9 +27,9 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserMusicRequestVO, get_all_pages from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def GetUserMusicDetail( def GetUserMusicDetail(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_new_item.py —— 取新增道具(GetUserNewItemListApi) GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
================================================================================ ================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。 上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
@@ -25,8 +25,8 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass @dataclass
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_option.py —— 取用户游戏设置(GetUserOptionApi) GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================ ================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。 取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
@@ -24,9 +24,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserOption( def GetUserOption(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_preview.py —— 用户预览(GetUserPreviewApi) GetUserPreview.py —— 用户预览(GetUserPreviewApi)
================================================================================ ================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。 功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
@@ -29,8 +29,8 @@ from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning from .LogSetup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass @dataclass
@@ -133,7 +133,7 @@ def GetUserPreview(
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
from .log_setup import log_error, setup_logging, teardown_logging from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("userpreview_test") setup_logging("userpreview_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
get_user_rating.py —— 取用户 Rating 明细(GetUserRatingApi) GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================ ================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。 取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
@@ -23,9 +23,9 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRating( def GetUserRating(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRecommendRateMusic( def GetUserRecommendRateMusic(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRegion( def GetUserRegion(
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import ( from .UserGetterCommon import (
_get, _get,
UserRivalRequestVO, UserRivalRequestVO,
UserRivalMusicRequestVO, UserRivalMusicRequestVO,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .log_setup import log_info from .LogSetup import log_info
from .maimai_net import MaimaiNetClient from .MaimaiNet import MaimaiNetClient
from .user_getter_common import _get, UserShopStockRequestVO from .UserGetterCommon import _get, UserShopStockRequestVO
def GetUserShopStock( def GetUserShopStock(
+1 -1
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式) LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================ ================================================================================
【两种模式】 【两种模式】
+2 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻) MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================ ================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。 本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
@@ -133,7 +133,7 @@ AES_IV = b"F>;24DjU9W6ZsRH["
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex #: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
OBFUSCATE_PARAM = "8bF76dE9" 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
# ============================================================================= # =============================================================================
# 协议常量 # 协议常量
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning from .LogSetup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
def Ping( def Ping(
@@ -80,7 +80,7 @@ def Ping(
if __name__ == "__main__": if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("ping_test") setup_logging("ping_test")
+168 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
upsert_user_all.py —— 上传成绩(UpsertUserAllApi) UpsertUserAll.py —— 上传成绩(UpsertUserAllApi)
================================================================================ ================================================================================
功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。 功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。
@@ -158,8 +158,8 @@ PLAY_AFTER_GRADE_RANK = 1
#: True = 从曲目记录推(仅调试用) #: True = 从曲目记录推(仅调试用)
DERIVE_GRADE_FROM_MUSIC_DETAIL = False DERIVE_GRADE_FROM_MUSIC_DETAIL = False
from .log_setup import log_debug, log_error, log_info, log_warning from .LogSetup import log_debug, log_error, log_info, log_warning
from .maimai_net import ( from .MaimaiNet import (
MaimaiNetClient, MaimaiNetClient,
local_utc_offset_seconds, local_utc_offset_seconds,
login_date_time_now, login_date_time_now,
@@ -552,7 +552,7 @@ def build_favorite_delta(
"2" = 服务器侧有对应 itemKind(无论内容是否一致) "2" = 服务器侧有对应 itemKind(无论内容是否一致)
"1" = 服务器侧查不到该 itemKind "1" = 服务器侧查不到该 itemKind
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 get_user_favorite.py ★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 GetUserFavorite.py
的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是 的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是
{"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。 {"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。
@@ -1894,6 +1894,117 @@ def apply_play_effects(
# 十二、组装 upsertUserAll # 十二、组装 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( def build_user_all(
snapshot: Optional[Any] = None, snapshot: Optional[Any] = None,
play: Optional[PlayData] = None, play: Optional[PlayData] = None,
@@ -1909,6 +2020,9 @@ def build_user_all(
playlog_id: int = 0, playlog_id: int = 0,
play_date_time: Optional[int] = None, play_date_time: Optional[int] = None,
extra_data: Optional[Any] = None, extra_data: Optional[Any] = None,
unlock_music_ids: Optional[Sequence[int]] = None,
unlock_master: bool = True,
unlock_remaster: bool = True,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
组装 upsertUserAll 的 36 个节点。 组装 upsertUserAll 的 36 个节点。
@@ -1932,6 +2046,10 @@ def build_user_all(
playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList) playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList)
extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径), extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径),
自动匹配插入 36 节点,**优先级高于 patch**。见 merge_extra_data。 自动匹配插入 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 36 节点的 dict
@@ -2039,6 +2157,34 @@ def build_user_all(
for note in merge_extra_data(user_all, extra_data): for note in merge_extra_data(user_all, extra_data):
log_info(note) 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()): for key in list(user_all.keys()):
if key.startswith("_"): if key.startswith("_"):
@@ -2350,16 +2496,16 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
def _lazy(name: str) -> Any: def _lazy(name: str) -> Any:
"""按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。""" """按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。"""
if name == "GetNeedUserData": if name == "GetNeedUserData":
from .get_need_user_data import GetNeedUserData from .GetNeedUserData import GetNeedUserData
return GetNeedUserData return GetNeedUserData
if name == "GetUserNewItemList": if name == "GetUserNewItemList":
from .get_user_new_item import GetUserNewItemList from .GetUserNewItem import GetUserNewItemList
return GetUserNewItemList return GetUserNewItemList
if name == "GetUserData": if name == "GetUserData":
from .get_user_data import GetUserData from .GetUserData import GetUserData
return GetUserData return GetUserData
if name == "GetUserMusicDetailAll": if name == "GetUserMusicDetailAll":
from .get_user_music import GetUserMusicDetailAll from .GetUserMusic import GetUserMusicDetailAll
return GetUserMusicDetailAll return GetUserMusicDetailAll
raise PatchError("未知的内部 api:" + str(name)) raise PatchError("未知的内部 api:" + str(name))
@@ -2478,6 +2624,9 @@ def UpsertUserAll(
auto_characters: bool = True, auto_characters: bool = True,
play: Optional[PlayData] = None, play: Optional[PlayData] = None,
extra_data: Optional[Any] = 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, patch: Optional[Dict[str, Any]] = None,
user_playlog_list: Optional[List[Any]] = None, user_playlog_list: Optional[List[Any]] = None,
pre_new_item_list: bool = True, pre_new_item_list: bool = True,
@@ -2533,6 +2682,14 @@ def UpsertUserAll(
"isNewKaleidxScopeList": "1"} "isNewKaleidxScopeList": "1"}
优先级最高(覆盖快照演算出的同名字段)。 优先级最高(覆盖快照演算出的同名字段)。
没给位串时会按数组长度补 "0" 并给出提示。 没给位串时会按数组长度补 "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,不补位串提示)。 patch: 同 extra_data 的旧接口(只收 dict,不补位串提示)。
user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条) user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条)
pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi
@@ -2624,6 +2781,8 @@ def UpsertUserAll(
# ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)---------- # ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)----------
user_all = build_user_all( user_all = build_user_all(
snapshot=snapshot, play=p, patch=patch, extra_data=extra_data, 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, login_date_time=login_date_time, auth_date_time=auth_date_time,
place_id=place_id, place_name=place_name, place_id=place_id, place_name=place_name,
region_id=region_id, region_name=region_name, client_id=client_id, region_id=region_id, region_name=region_name, client_id=client_id,
@@ -2709,7 +2868,7 @@ def UpsertUserAll(
# ============================================================================= # =============================================================================
if __name__ == "__main__": if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("upsert_user_all_test") setup_logging("upsert_user_all_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
r""" r"""
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi) UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================ ================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。 功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
@@ -140,9 +140,15 @@ PAID_TICKET_IDS = (2, 3, 4, 5, 6)
TICKET_VALID_HOUR_OFFSET = -4 TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90 TICKET_VALID_DAYS = 90
from .log_setup import log_debug, log_error, log_info, log_warning from .ChargeGuard import (
from .maimai_net import MaimaiNetClient, request_dict has_fresh_read,
from .upsert_user_all import UpsertResponseVO, resolve_api_func last_read_age,
note_charge_purchase,
purchased_charge_ids,
)
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
from .UpsertUserAll import UpsertResponseVO, resolve_api_func
class ChargelogConfirmError(RuntimeError): class ChargelogConfirmError(RuntimeError):
@@ -334,6 +340,8 @@ def UpsertUserChargelog(
client_id: str = CLIENT_ID, client_id: str = CLIENT_ID,
guard_no_stock: bool = True, guard_no_stock: bool = True,
guard_purchasable: bool = True, guard_purchasable: bool = True,
guard_require_charge_read: bool = True,
guard_single_purchase: bool = True,
confirm: Optional[bool] = None, confirm: Optional[bool] = None,
dry_run: bool = False, dry_run: bool = False,
timeout_ms: Optional[int] = None, timeout_ms: Optional[int] = None,
@@ -368,6 +376,16 @@ def UpsertUserChargelog(
(抛 ChargelogGuardError,未发包)。 (抛 ChargelogGuardError,未发包)。
理由见 ChargelogGuardError 的 docstring —— 有封号风险。 理由见 ChargelogGuardError 的 docstring —— 有封号风险。
确认要强行购买才传 False。 确认要强行购买才传 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 confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
dry_run: True = 只构造并返回报文,绝不发包 dry_run: True = 只构造并返回报文,绝不发包
@@ -387,6 +405,34 @@ def UpsertUserChargelog(
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS=" log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
+ str(tuple(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. 取当前状态 ---------- # ---------- 1. 取当前状态 ----------
if charge_list is None: if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...") log_info("未传 charge_list,自动抓取当前票券 ...")
@@ -540,6 +586,8 @@ def UpsertUserChargelog(
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName) log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock)) log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
note_charge_purchase(user_id, charge_id)
return resp return resp
@@ -548,7 +596,7 @@ def UpsertUserChargelog(
# ============================================================================= # =============================================================================
if __name__ == "__main__": if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("chargelog_test") setup_logging("chargelog_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- 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 config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_warning from .LogSetup import log_debug, log_warning
from .maimai_net import MaimaiNetClient, get_default_client from .MaimaiNet import MaimaiNetClient, get_default_client
# ============================================================================= # =============================================================================
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
userlogin.py —— 用户正式登录(UserLoginApi) UserLogin.py —— 用户正式登录(UserLoginApi)
================================================================================ ================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。 功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
@@ -48,8 +48,8 @@ from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .log_setup import log_debug, log_error, log_info from .LogSetup import log_debug, log_error, log_info
from .maimai_net import ( from .MaimaiNet import (
MaimaiNetClient, MaimaiNetClient,
local_utc_offset_seconds, local_utc_offset_seconds,
login_date_time_now, login_date_time_now,
@@ -198,7 +198,7 @@ def UserLogin(
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("userlogin_test") setup_logging("userlogin_test")
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
userlogout.py —— 用户登出(UserLogoutApi) UserLogout.py —— 用户登出(UserLogoutApi)
================================================================================ ================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。 功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
@@ -39,8 +39,8 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_error, log_info from .LogSetup import log_debug, log_error, log_info
from .maimai_net import MaimaiNetClient, request_dict from .MaimaiNet import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17) #: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26 DELAY_LOG_SLOTS = 26
@@ -228,7 +228,7 @@ def UserLogout(
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys
from .log_setup import setup_logging, teardown_logging from .LogSetup import setup_logging, teardown_logging
def _main() -> int: def _main() -> int:
setup_logging("userlogout_test") setup_logging("userlogout_test")
+111 -78
View File
@@ -20,59 +20,60 @@ api 包 —— 统一导出
目录结构(一个 api 一个脚本) 目录结构(一个 api 一个脚本)
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
基础设施 基础设施
log_setup.py 统一日志(INFO / DEBUG 双模式) LogSetup.py 统一日志(INFO / DEBUG 双模式)
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie) MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页) UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路 会话链路
chime.py 扫码登录 -> userId + token Chime.py 扫码登录 -> userId + token
get_game_settings.py GetGameSettingApi(游戏设置) GetGameSettings.py GetGameSettingApi(游戏设置)
ping.py 心跳探活 Ping.py 心跳探活
get_user_preview.py GetUserPreviewApi(用户预览) GetUserPreview.py GetUserPreviewApi(用户预览)
userlogin.py UserLoginApi(正式登录 -> loginId + loginDateTime) UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
userlogout.py UserLogoutApi(登出) UserLogout.py UserLogoutApi(登出)
上传 / 结算(有副作用的接口) 上传 / 结算(有副作用的接口)
upsert_user_all.py ★ UpsertUserAllApi(上传成绩) UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
upsert_user_chargelog.py UpsertUserChargelogApi(购买功能票) UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
get_need_user_data.py ★ 结算快照编排(统一调用下面的取数脚本), GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json 并把结果落盘到 <工程根>/data/<userId>.json
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get** ★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
(不要模棱两可)。所以下面这些文件全是 get_ 开头。 (不要模棱两可)。所以下面这些文件全是 get_ 开头。
取数 —— GetUser*(一个接口一个脚本) 取数 —— GetUser*(一个接口一个脚本)
get_user_data.py GetUserDataApi GetUserData.py GetUserDataApi
get_user_extend.py GetUserExtendApi GetUserExtend.py GetUserExtendApi
get_user_option.py GetUserOptionApi GetUserOption.py GetUserOptionApi
get_user_character.py GetUserCharacterApi GetUserCharacter.py GetUserCharacterApi
get_user_ghost.py GetUserGhostApi GetUserGhost.py GetUserGhostApi
get_user_map.py GetUserMapApi GetUserMap.py GetUserMapApi
get_user_login_bonus.py GetUserLoginBonusApi GetUserLoginBonus.py GetUserLoginBonusApi
get_user_rating.py GetUserRatingApi GetUserRating.py GetUserRatingApi
get_user_item.py GetUserItemApi GetUserItem.py GetUserItemApi
get_user_music.py GetUserMusicApi GetUserMusic.py GetUserMusicApi
get_user_course.py GetUserCourseApi GetUserCourse.py GetUserCourseApi
get_user_charge.py GetUserChargeApi GetUserCharge.py GetUserChargeApi
get_user_favorite.py GetUserFavoriteApi GetUserFavorite.py GetUserFavoriteApi
get_user_activity.py GetUserActivityApi GetUserActivity.py GetUserActivityApi
get_user_mission.py GetUserMissionDataApi GetUserMission.py GetUserMissionDataApi
get_user_intimate.py GetUserIntimateApi GetUserIntimate.py GetUserIntimateApi
get_user_shop_stock.py GetUserShopStockApi GetUserShopStock.py GetUserShopStockApi
get_user_favorite_item.py GetUserFavoriteItemApi GetUserFavoriteItem.py GetUserFavoriteItemApi
get_user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
get_user_friend_bonus.py GetUserFriendBonusApi GetUserFriendBonus.py GetUserFriendBonusApi
get_user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
get_user_region.py GetUserRegionApi GetUserRegion.py GetUserRegionApi
get_user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
get_user_new_item.py GetUserNewItemListApi GetUserNewItem.py GetUserNewItemListApi
取数 —— GetGame* 取数 —— GetGame*
get_game_settings.py GetGameSettingApi GetGameSettings.py GetGameSettingApi
get_game_charge.py GetGameChargeApi GetGameCharge.py GetGameChargeApi
""" """
from .log_setup import ( from .LogSetup import (
# 会话 # 会话
setup_logging, setup_logging,
teardown_logging, teardown_logging,
@@ -107,8 +108,8 @@ from .log_setup import (
# 1) 网络核心 # 1) 网络核心
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .maimai_net import ( from .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 maimai_net.py) # 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL, BASE_URL,
AES_KEY, AES_KEY,
AES_IV, AES_IV,
@@ -141,9 +142,9 @@ from .maimai_net import (
# 2) 会话链路 # 2) 会话链路
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .chime import ( from .Chime import (
ParseQRCode, ParseQRCode,
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py) # aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
AIMEDB_HOST, AIMEDB_HOST,
AIMEDB_PORT, AIMEDB_PORT,
AIMEDB_PATH, AIMEDB_PATH,
@@ -153,7 +154,7 @@ from .chime import (
QR_USER_AGENT, QR_USER_AGENT,
) )
from .get_game_settings import ( from .GetGameSettings import (
GetGameSettings, GetGameSettings,
GameSetting, GameSetting,
GameSettingRequestVO, GameSettingRequestVO,
@@ -162,21 +163,21 @@ from .get_game_settings import (
is_ping_disabled, is_ping_disabled,
) )
from .ping import Ping from .Ping import Ping
from .get_user_preview import ( from .GetUserPreview import (
GetUserPreview, GetUserPreview,
UserPreviewRequestVO, UserPreviewRequestVO,
UserPreviewResponseVO, UserPreviewResponseVO,
) )
from .userlogin import ( from .UserLogin import (
UserLogin, UserLogin,
UserLoginRequestVO, UserLoginRequestVO,
UserLoginResponseVO, UserLoginResponseVO,
) )
from .userlogout import ( from .UserLogout import (
UserLogout, UserLogout,
LogoutType, LogoutType,
DelayLog, DelayLog,
@@ -193,7 +194,7 @@ from .userlogout import (
# 3) 上传成绩 # 3) 上传成绩
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .upsert_user_all import ( from .UpsertUserAll import (
UpsertUserAll, UpsertUserAll,
PlayData, PlayData,
build_play_from_config, build_play_from_config,
@@ -236,6 +237,13 @@ from .upsert_user_all import (
load_extra_data, load_extra_data,
merge_extra_data, merge_extra_data,
verify_upsert_landing, 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_weekly_data,
default_user_2p_playlog, default_user_2p_playlog,
calc_play_special, calc_play_special,
@@ -255,7 +263,7 @@ from .upsert_user_all import (
USER_MISSION_DROP_KEYS, USER_MISSION_DROP_KEYS,
) )
from .get_need_user_data import ( from .GetNeedUserData import (
GetNeedUserData, GetNeedUserData,
save_snapshot, save_snapshot,
load_snapshot, load_snapshot,
@@ -270,8 +278,24 @@ from .get_need_user_data import (
list_saved_user_ids, 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_CREDIT_NUM,
TICKET_NAMES, TICKET_NAMES,
PAID_TICKET_IDS, PAID_TICKET_IDS,
@@ -294,7 +318,7 @@ from .upsert_user_chargelog import (
# 4) 取数公共底座 # 4) 取数公共底座
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .user_getter_common import ( from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO, EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO, UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO, UserIdPageRequestVO,
@@ -318,37 +342,37 @@ from .user_getter_common import (
# 5) 取数脚本(一个接口一个脚本) # 5) 取数脚本(一个接口一个脚本)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from .get_user_data import GetUserData from .GetUserData import GetUserData
from .get_user_extend import GetUserExtend from .GetUserExtend import GetUserExtend
from .get_user_option import GetUserOption from .GetUserOption import GetUserOption
from .get_user_character import GetUserCharacter from .GetUserCharacter import GetUserCharacter
from .get_user_ghost import GetUserGhost from .GetUserGhost import GetUserGhost
from .get_user_map import GetUserMap, GetUserMapAll from .GetUserMap import GetUserMap, GetUserMapAll
from .get_user_login_bonus import GetUserLoginBonus, GetUserLoginBonusAll from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
from .get_user_rating import GetUserRating from .GetUserRating import GetUserRating
from .get_user_item import GetUserItem, GetUserItemAll from .GetUserItem import GetUserItem, GetUserItemAll
from .get_user_music import GetUserMusicDetail, GetUserMusicDetailAll from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
from .get_user_course import GetUserCourse, GetUserCourseAll from .GetUserCourse import GetUserCourse, GetUserCourseAll
from .get_user_charge import GetUserCharge from .GetUserCharge import GetUserCharge
from .get_user_favorite import GetUserFavorite, GetUserFavoriteAll from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
from .get_user_activity import GetUserActivity from .GetUserActivity import GetUserActivity
from .get_user_mission import GetUserMissionData from .GetUserMission import GetUserMissionData
from .get_user_intimate import GetUserIntimate from .GetUserIntimate import GetUserIntimate
from .get_user_shop_stock import GetUserShopStock from .GetUserShopStock import GetUserShopStock
from .get_user_favorite_item import ( from .GetUserFavoriteItem import (
GetUserFavoriteItem, GetUserFavoriteItem,
GetUserFavoriteItemAll, GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC, FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL, FAVORITE_ITEM_KIND_RIVAL,
) )
from .get_user_rival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .get_user_friend_bonus import GetUserFriendBonus, extract_gift_points from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
from .get_user_kaleidx_scope import GetUserKaleidxScope, GetGameKaleidxScope from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
from .get_user_region import GetUserRegion from .GetUserRegion import GetUserRegion
from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
from .get_game_charge import GetGameCharge, GameChargeRequestVO from .GetGameCharge import GetGameCharge, GameChargeRequestVO
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里) # 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
@@ -369,6 +393,7 @@ _API_FUNCS = (
"merge_extra_data", "merge_extra_data",
"verify_upsert_landing", "verify_upsert_landing",
"build_user_all", "build_user_all",
"build_unlock_items",
# --- 购买功能票 --- # --- 购买功能票 ---
"UpsertUserChargelog", "UpsertUserChargelog",
"ticket_name", "ticket_name",
@@ -471,8 +496,16 @@ __all__ = [
# ---- 上传成绩 ---- # ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters", "UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing", "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", "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", "UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst", "ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO", "UserAllRequestVO", "UpsertResponseVO",
-45
View File
@@ -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
View File
@@ -8,11 +8,14 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
★ 本脚本判断「能买什么票」**只看 GetUserChargeApi**(账号自己的票券表),
不再调 GetGameChargeApi(那是机台本地的上架清单,跟账号状态无关)。
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管): UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」) 1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
2. 调 GetUserDataApi 取 playCount / playerRating 2. 调 GetUserDataApi 取 playCount / playerRating
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00) 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) userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包 5. 闸门 → 发包
@@ -20,7 +23,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
用法 用法
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包 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 3 # 指定票(默认 5 = 5倍券)
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买 python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
python buy_ticket.py <二维码> --charge-id 2 --send --debug python buy_ticket.py <二维码> --charge-id 2 --send --debug
@@ -60,7 +63,6 @@ from api import (
ChargelogConfirmError, ChargelogConfirmError,
ChargelogGuardError, ChargelogGuardError,
GetUserCharge, GetUserCharge,
GetGameCharge,
ticket_name, ticket_name,
GetNeedUserData, GetNeedUserData,
summarize_snapshot, summarize_snapshot,
@@ -99,7 +101,7 @@ def show_charges(user_id, label):
def parse_args(argv): def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False, 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 i = 0
while i < len(argv): while i < len(argv):
a = argv[i] a = argv[i]
@@ -136,27 +138,6 @@ def main() -> int:
setup_logging("buy_ticket") setup_logging("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False} session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try: 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 头 ---- # ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。 # 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。 # 不跑的话 number 恒为 "0"(真机是 75)。
@@ -187,6 +168,10 @@ def main() -> int:
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ---- # ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前") held_before = show_charges(user_id, "购买前")
if opts["list"]:
log_info("[--list] 只查询票券状态,不购买。")
return 0
aborted = False aborted = False
if held_before: if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):") log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
@@ -199,6 +184,7 @@ def main() -> int:
aborted = True aborted = True
else: else:
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)---- # ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
# 默认 15 秒(与 UpsertUserAll 的发包冷却一致)
if opts["wait_before"] > 0: if opts["wait_before"] > 0:
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...") log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
time.sleep(opts["wait_before"]) time.sleep(opts["wait_before"])
@@ -211,6 +197,7 @@ def main() -> int:
dry_run=not opts["send"], dry_run=not opts["send"],
confirm=opts["send"], confirm=opts["send"],
guard_no_stock=True, guard_no_stock=True,
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
place_id=PLACE_ID, region_id=REGION_ID, place_id=PLACE_ID, region_id=REGION_ID,
) )
+5 -5
View File
@@ -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" # 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 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 # 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md # + docs/舞萌DX_扫码登录协议技术规范.md
# ★ aimedb / QR 常量已下沉到 api/chime.py # ★ aimedb / QR 常量已下沉到 api/Chime.py
# ============================================================================= # =============================================================================
# 三之二、★ 功能票(Ticket)主数据 # 三之二、★ 功能票(Ticket)主数据
# ============================================================================= # =============================================================================
@@ -128,7 +128,7 @@ AUTHV1_WRITE_CONFIG = False
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara # 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS / # ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS # TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
# 已下沉到 api/upsert_user_chargelog.py # 已下沉到 api/UpsertUserChargelog.py
#: 买票闸门(和 UpsertUserAll 一样,默认不放行) #: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False UPSERT_CHARGELOG_CONFIRM = False
@@ -227,7 +227,7 @@ PLAY_IS_FREEDOM_MODE = False
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。 # 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。 # 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。 # 真机实测: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"(机台硬件标志位)--- # --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
# #
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378 # 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
@@ -256,7 +256,7 @@ PLAY_IS_FREEDOM_MODE = False
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。 # 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
# #
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。 # 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py # ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
# --- 用户总游玩次数增量 --- # --- 用户总游玩次数增量 ---
# #
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。 # ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

+156 -3
View File
@@ -27,6 +27,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import api import api
import unlockmusic
import upsert
from api import ( from api import (
BITS_TO_ARRAY, BITS_TO_ARRAY,
JUDGE_BOXES, JUDGE_BOXES,
@@ -38,6 +40,7 @@ from api import (
build_delta, build_delta,
build_favorite_delta, build_favorite_delta,
build_play_from_config, build_play_from_config,
build_unlock_items,
build_user_all, build_user_all,
build_user_game_playlog, build_user_game_playlog,
build_user_playlog, build_user_playlog,
@@ -49,6 +52,13 @@ from api import (
UpsertUserChargelog, UpsertUserChargelog,
ChargelogConfirmError, ChargelogConfirmError,
ChargelogGuardError, 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_name,
ticket_price, ticket_price,
calc_valid_date, calc_valid_date,
@@ -70,13 +80,13 @@ from api import (
login_date_time_now, login_date_time_now,
local_utc_offset_seconds, local_utc_offset_seconds,
) )
from api.upsert_user_all import ( from api.UpsertUserAll import (
ConfirmRequiredError, ConfirmRequiredError,
PatchError, PatchError,
UpsertUserAll, UpsertUserAll,
UserAllRequestVO, UserAllRequestVO,
) )
from api.upsert_user_chargelog import _now_string from api.UpsertUserChargelog import _now_string
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。 # ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
from api import ( 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) _, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_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 显式声明。 # 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)] 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,)) _, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
@@ -1102,6 +1112,149 @@ except ChargelogConfirmError:
check("买票未开闸门抛 ChargelogConfirmError(未发包)", True) 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. 副作用闸门 # 14. 副作用闸门
# ============================================================================= # =============================================================================
+80 -14
View File
@@ -20,28 +20,91 @@ python chart_achievement.py # 不给参数 = 交互向导
| `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` | | `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` |
| `--game <目录> --music-id <ID>` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 | | `--game <目录> --music-id <ID>` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 |
`--difficulty basic|advanced|expert|master|remaster`(也接受 0~4),默认 **master**。 > 🔎 **不知道 musicId / 定数?** [maimai.lxns.net/songs](https://maimai.lxns.net/songs)
`--level` 只用于显示(如 `12+`),不参与计算。 > 可以在线查询谱面信息(曲名、难度、定数等),查到 `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 分 ## 正向:已知判定数 → 达成率 + DX 分
达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500),
所以有两种给判定数的方式:
### ① 精确:按音符类别给(推荐,得到确切数值)
```powershell ```powershell
python chart_achievement.py --chart "<...>\000008_03.ma2" --level 12+ --cp 290 --perfect 8 --great 3 --good 1 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/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略),
(`miss = 总音符数 - CP - Perfect - Great - Good`)。 **没给满的部分按 CP 算**:
- 输出达成率的**区间**(下界 ~ 上界)和「按比例分摊」的参考值:
只给聚合判定数时,达成率取决于这些 Great/Good/Miss **落在哪些音符上** | 参数 | 类别 | 权重 |
(落在绝赞上扣得多、落在 Tap 上扣得少),所以是一个区间而不是一个数。 | --- | --- | --- |
下界 = 扣分尽量落在高权重音符上,上界 = 尽量落在低权重音符上。 | `--tap` | Tap / Star / Touch / EX | 500 |
- DX 分是确定的:`3×CP + 2×Perfect + 1×Great`。 | `--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 分 ## 反向:已知达成率 → 判定组合 + DX 分
```powershell ```powershell
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --achievement 100.75 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 --achievement 99 --mode range python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 2 --achievement 99 --mode range # 2 = EXPERT
``` ```
两种模式(`--mode`): 两种模式(`--mode`):
@@ -128,8 +191,11 @@ CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不
## 已知局限 ## 已知局限
- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布(例如绝赞 Perfect 是 75 还是 50 档, - 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%)
正向计算里只能给区间)。 和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` /
`--break-perfect50` 指定,不给就按 20% / 75 算。
- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数,
落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。
- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。 - 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。
- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告 - 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告
(全部 5419 个游戏谱面实测一致)。 (全部 5419 个游戏谱面实测一致)。
+238 -43
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import re
import sys import sys
from fractions import Fraction from fractions import Fraction
from typing import Dict, List, Optional from typing import Dict, List, Optional
@@ -22,9 +23,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart, from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart,
chart_path, find_music_dir, level_display, parse_difficulty, chart_path, find_music_dir, level_display, parse_difficulty,
parse_ma2, parse_music_xml) parse_ma2, parse_music_xml)
from score import (DX_PER, JUDGE_LABEL, JUDGMENTS, Theory, dx_score, from score import (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL,
reference_achievement, solve_forward, solve_reverse, JUDGMENTS, Theory, class_capacity, dx_score,
theory_from_chart) reference_achievement, solve_exact, solve_forward,
solve_reverse, theory_from_chart)
try: # 中文输出到管道时避免 UnicodeEncodeError try: # 中文输出到管道时避免 UnicodeEncodeError
sys.stdout.reconfigure(errors="replace") sys.stdout.reconfigure(errors="replace")
@@ -101,7 +103,7 @@ def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
# ------------------------------------------------------------------ 展示 # ------------------------------------------------------------------ 展示
def print_chart(chart: Chart, th: Theory, level: Optional[str]) -> None: def print_chart(chart: Chart, th: Theory) -> None:
print(LINE) print(LINE)
print("谱面") print("谱面")
print(" 文件 %s" % chart.path) print(" 文件 %s" % chart.path)
@@ -110,10 +112,10 @@ def print_chart(chart: Chart, th: Theory, level: Optional[str]) -> None:
if chart.music_id is not None: if chart.music_id is not None:
print(" 编号 %d" % chart.music_id) print(" 编号 %d" % chart.music_id)
if chart.difficulty_name: if chart.difficulty_name:
print(" 难度 %s (%02d)" % (chart.difficulty_name, chart.difficulty_index or 0)) print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
shown_level = level or chart.level_text chart.difficulty_index if chart.difficulty_index is not None else 0))
if shown_level: if chart.level_text:
print(" 等级 %s" % shown_level) print(" 等级 %s" % chart.level_text)
parts = [] parts = []
for g in GROUP_ORDER: for g in GROUP_ORDER:
n = th.counts.get(g, 0) n = th.counts.get(g, 0)
@@ -138,20 +140,70 @@ def print_chart(chart: Chart, th: Theory, level: Optional[str]) -> None:
def print_forward(chart: Chart, result) -> None: def print_forward(chart: Chart, result) -> None:
th = result.theory th = result.theory
print(LINE) print(LINE)
print("判定输入") print("判定输入(汇总)")
for j in JUDGMENTS: for j in JUDGMENTS:
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0))) print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes)) print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
print(LINE) print(LINE)
print("结果") print("结果")
print(" 达成率 %s ~ %s" % (pct(result.achievement_min), pct(result.achievement_max))) exact = result.achievement_min == result.achievement_max
print(" 按比例参考 %s" % pct(result.achievement_ref)) 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%%)" print(" DX 分 %d / %d (%.2f%%)"
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0)) % (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
if result.achievement_min != result.achievement_max: if not exact:
print() print()
print(" 说明:只给聚合判定数时,达成率取决于「哪些音符」被判成 Great/Good/Miss——") print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——")
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。") 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) print(LINE)
@@ -210,7 +262,7 @@ def print_reverse(chart: Chart, outcome) -> None:
# ------------------------------------------------------------------ JSON # ------------------------------------------------------------------ JSON
def to_json(chart: Chart, th: Theory, level: Optional[str], payload: Dict) -> str: def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
data = { data = {
"chart": { "chart": {
"path": chart.path, "path": chart.path,
@@ -218,7 +270,7 @@ def to_json(chart: Chart, th: Theory, level: Optional[str], payload: Dict) -> st
"song_name": chart.song_name, "song_name": chart.song_name,
"difficulty": chart.difficulty_name, "difficulty": chart.difficulty_name,
"difficulty_index": chart.difficulty_index, "difficulty_index": chart.difficulty_index,
"level": level or chart.level_text, "level_text": chart.level_text,
"notes": th.counts, "notes": th.counts,
"total_notes": th.total_notes, "total_notes": th.total_notes,
"connect_slide": chart.connect_count, "connect_slide": chart.connect_count,
@@ -267,7 +319,7 @@ def interactive(args) -> None:
mid = ask_int("musicid: ") mid = ask_int("musicid: ")
if mid is None: if mid is None:
continue continue
diff = ask("难度 basic/advanced/expert/master/remaster [master]: ", "master") diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
try: try:
chart = load_from_game(game, mid, parse_difficulty(diff)) chart = load_from_game(game, mid, parse_difficulty(diff))
except SystemExit: except SystemExit:
@@ -281,11 +333,11 @@ def interactive(args) -> None:
except SystemExit: except SystemExit:
chart = None chart = None
th = theory_from_chart(chart) th = theory_from_chart(chart)
level = ask("等级(仅用于显示,可留空): ") or None print_chart(chart, th)
print_chart(chart, th, level)
mode = ask("计算模式:1) 已知判定数算达成率 2) 已知达成率倒算判定数 [1]: ", "1") mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)"
if mode.startswith("2"): " 3) 由达成率倒算判定数 [1]: ", "1")
if mode.startswith("3"):
while True: while True:
raw = ask("目标达成率(如 99.5 或 100.5): ") raw = ask("目标达成率(如 99.5 或 100.5): ")
if not raw: if not raw:
@@ -297,7 +349,22 @@ def interactive(args) -> None:
print(" 请输入数字") print(" 请输入数字")
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1") mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
args.mode = "range" if mode.startswith("2") else "exact" args.mode = "range" if mode.startswith("2") else "exact"
run_reverse(chart, th, level, target, args) 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: else:
values = {} values = {}
for j in JUDGMENTS: for j in JUDGMENTS:
@@ -305,7 +372,7 @@ def interactive(args) -> None:
continue continue
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0 values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ") values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
run_forward(chart, th, level, values, args) run_forward(chart, th, values, args)
# ------------------------------------------------------------------ 执行 # ------------------------------------------------------------------ 执行
@@ -322,12 +389,89 @@ def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int
return given return given
def run_forward(chart: Chart, th: Theory, level: Optional[str], _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: raw: Dict[str, Optional[int]], args) -> None:
judges = normalize_judges(th, raw) judges = normalize_judges(th, raw)
result = solve_forward(th, judges) result = solve_forward(th, judges)
if args.json: if args.json:
print(to_json(chart, th, level, { print(to_json(chart, th, {
"mode": "forward", "mode": "forward",
"judges": judges, "judges": judges,
"achievement_min": float(result.achievement_min), "achievement_min": float(result.achievement_min),
@@ -336,15 +480,15 @@ def run_forward(chart: Chart, th: Theory, level: Optional[str],
"dx_score": result.dx, "dx_score": result.dx,
})) }))
return return
print_chart(chart, th, level) print_chart(chart, th)
print_forward(chart, result) print_forward(chart, result)
def run_reverse(chart: Chart, th: Theory, level: Optional[str], def run_reverse(chart: Chart, th: Theory,
target: Fraction, args) -> None: target: Fraction, args) -> None:
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode) outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
if args.json: if args.json:
print(to_json(chart, th, level, { print(to_json(chart, th, {
"mode": "reverse", "mode": "reverse",
"search_mode": outcome["mode"], "search_mode": outcome["mode"],
"target": float(target), "target": float(target),
@@ -363,7 +507,7 @@ def run_reverse(chart: Chart, th: Theory, level: Optional[str],
} for r in outcome["rows"]], } for r in outcome["rows"]],
})) }))
return return
print_chart(chart, th, level) print_chart(chart, th)
print_reverse(chart, outcome) print_reverse(chart, outcome)
@@ -373,33 +517,53 @@ def build_parser() -> argparse.ArgumentParser:
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)", description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例: epilog="""示例:
# 直接给谱面文件,正向计算 # 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间)
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --level 12+ --cp 290 --perfect 10 --great 2 python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
# 给游戏路径 + musicid(默认 Master 难度) # 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的)
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --difficulty master --cp 300 --perfect 2 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 --achievement 99.5 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 --achievement 99.5 --mode range 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 = p.add_argument_group("谱面来源(二选一)")
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径") src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)") 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("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
src.add_argument("--difficulty", default="master", metavar="D", src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
help="难度 basic/advanced/expert/master/remaster 或 0~4(默认 master)") help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
src.add_argument("--level", metavar="TEXT", help="等级,仅用于显示(如 12+ / 13.4)") "3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
fw = p.add_argument_group("正向计算:已知判定数") fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间")
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数") fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数") fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
fw.add_argument("--great", type=int, metavar="N", help="Great 个数") fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
fw.add_argument("--good", type=int, metavar="N", help="Good 个数") fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)") 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 = p.add_argument_group("反向计算:已知达成率")
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)") rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
rv.add_argument("--mode", choices=("exact", "range"), default="exact", rv.add_argument("--mode", choices=("exact", "range"), default="exact",
@@ -422,10 +586,26 @@ def main(argv: Optional[List[str]] = None) -> int:
return 0 return 0
if args.chart: if args.chart:
chart = load_from_file(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: else:
if args.music_id is None: if args.music_id is None:
die("使用 --game 时必须同时给 --music-id") die("使用 --game 时必须同时给 --music-id")
chart = load_from_game(args.game, args.music_id, parse_difficulty(args.difficulty)) 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) th = theory_from_chart(chart)
if th.total_notes == 0: if th.total_notes == 0:
@@ -440,15 +620,30 @@ def main(argv: Optional[List[str]] = None) -> int:
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement))) die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
if target < 0: if target < 0:
die("目标达成率不能为负") die("目标达成率不能为负")
run_reverse(chart, th, args.level, target, args) 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 return 0
raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great, raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great,
"good": args.good, "miss": args.miss} "good": args.good, "miss": args.miss}
if all(v is None for v in raw.values()): if all(v is None for v in raw.values()):
die("请给判定数(--cp/--perfect/--great/--good),或用 --achievement 做反向计算;" die("请给判定数:汇总用 --cp/--perfect/--great/--good,"
"两个都不给则进入交互模式(需去掉其它参数)") "精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算")
run_forward(chart, th, args.level, raw, args) run_forward(chart, th, raw, args)
return 0 return 0
+2 -1
View File
@@ -274,7 +274,8 @@ def parse_difficulty(text) -> int:
key = str(text).strip().lower() key = str(text).strip().lower()
if key in _DIFFICULTY_ALIASES: if key in _DIFFICULTY_ALIASES:
return _DIFFICULTY_ALIASES[key] return _DIFFICULTY_ALIASES[key]
raise ValueError("无法识别的难度:%s(可用 basic/advanced/expert/master/remaster 或 0~4)" % text) raise ValueError("无法识别的难度:%s(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER,"
"或直接写名字)" % text)
def find_music_dir(game_path: str) -> str: def find_music_dir(game_path: str) -> str:
+103
View File
@@ -427,6 +427,109 @@ def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult:
) )
# ------------------------------------------------------------------ 正向:按音符类别精确计算
# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间;
# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。
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, def _judges_for(th: Theory, miss: int, good: int, great: int,
cp: int, perfect: int) -> Dict[str, int]: cp: int, perfect: int) -> Dict[str, int]:
+138
View File
@@ -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())
+39 -6
View File
@@ -29,9 +29,12 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包 python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销) python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文 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 upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
# ★ 解锁(解禁)歌曲请用另一个主脚本(它复用本脚本的整套流程)
python unlockmusic.py <二维码> --send --music 11414
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的) # ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
python upsert.py <二维码> --send --extra-data extra.json python upsert.py <二维码> --send --extra-data extra.json
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}' python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
@@ -83,6 +86,17 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
新增条目请显式传 "1","0" 表示存量更新。 新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。 ★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
--------------------------------------------------------------------------------
★ 这个文件同时是一个可复用的模块
--------------------------------------------------------------------------------
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
import upsert
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
── 在代码里直接调 api 的等价写法 ── ── 在代码里直接调 api 的等价写法 ──
UpsertUserAll( UpsertUserAll(
@@ -146,19 +160,24 @@ def parse_args(argv):
return opts return opts
def main() -> int: def run(opts, *, unlock_music=None, unlock_master=True, unlock_remaster=True,
opts = parse_args(sys.argv[1:]) log_name="upsert") -> int:
if not opts["qr"]: """跑完整套流程。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__) print(__doc__)
return 1 return 1
# --debug 只影响本次运行,不改 config.py # --debug 只影响本次运行,不改 config.py
if opts["debug"]: if opts.get("debug"):
import config as _cfg import config as _cfg
_cfg.LOG_LEVEL = "debug" _cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志 # 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
setup_logging("upsert") setup_logging(log_name)
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)---- # ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None extra = None
@@ -186,6 +205,13 @@ def main() -> int:
extra = merged extra = merged
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra)) 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) # 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False} session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try: try:
@@ -210,6 +236,9 @@ def main() -> int:
auth_date_time=login.dateTime, auth_date_time=login.dateTime,
token=token, token=token,
extra_data=extra, extra_data=extra,
unlock_music_ids=unlock_music or None,
unlock_master=unlock_master,
unlock_remaster=unlock_remaster,
dry_run=not opts["send"], dry_run=not opts["send"],
confirm=opts["send"], confirm=opts["send"],
cooldown_sec=opts["cooldown"], cooldown_sec=opts["cooldown"],
@@ -235,5 +264,9 @@ def main() -> int:
teardown_logging() teardown_logging()
def main() -> int:
return run(parse_args(sys.argv[1:]), log_name="upsert")
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())
+1 -1
View File
@@ -78,7 +78,7 @@ from api import (
ConfirmRequiredError, ConfirmRequiredError,
ticket_name, ticket_name,
Ping, Ping,
# ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py # ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
TICKET_CREDIT_NUM, TICKET_CREDIT_NUM,
) )
from config import ( from config import (