重构:常量下沉到 api 模块 + 删无用脚本 + rating/grade 改为运行时推导
1) 删除 5 个无用的取数脚本(连同 __init__ 的导出):
get_game_event / get_game_ng_music_id / get_game_ranking /
get_game_tournament_info / get_game_weekly_data
2) config.py 常量下沉(config 只留「机台/店铺标识」和闸门/日志):
- BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM -> api/maimai_net.py
- AIMEDB_* / QR_* -> api/chime.py
- TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS -> api/upsert_user_chargelog.py
- PLAY_SPECIAL_NUM -> api/upsert_user_all.py
- PLAY_CHARACTERS -> selftest.py(作为测试数据)
下沉后的常量仍从 api 包统一导出,调用点无需改 import 路径。
3) PlayData 字段改造:
- characters 默认空列表(真值由 derive_play_characters 从快照推)
- local_play_count 恒为 2
- after_grade_rank 恒为 1
4) rating / grade 改为运行时从服务器快照取:
- 新增 derive_play_rating():
before/afterRating <- userData.playerRating
before/afterDeluxRating <- userData.musicRating
(真机实证 userRatingList[0].rating 是 0,不是这个值)
- 新增 derive_play_grade() + find_music_detail():
在 userMusicDetailList 里搜同 (musicId, level) 取 scoreRank
DERIVE_GRADE_FROM_MUSIC_DETAIL=False 可切回严格复刻真机(一律 0)
⚠ 源码实证这三个字段其实是段位 Dan(VOExtensions.cs:1419-1421)
自检 238 项全绿。
This commit is contained in:
+35
-19
@@ -69,12 +69,7 @@ api 包 —— 统一导出
|
||||
|
||||
取数 —— GetGame*
|
||||
get_game_settings.py GetGameSettingApi
|
||||
get_game_ranking.py GetGameRankingApi
|
||||
get_game_event.py GetGameEventApi
|
||||
get_game_charge.py GetGameChargeApi
|
||||
get_game_weekly_data.py GetGameWeeklyDataApi
|
||||
get_game_tournament_info.py GetGameTournamentInfoApi
|
||||
get_game_ng_music_id.py GetGameNgMusicIdApi
|
||||
"""
|
||||
|
||||
from .log_setup import (
|
||||
@@ -113,6 +108,11 @@ from .log_setup import (
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .maimai_net import (
|
||||
# 协议常量(原在 config.py,已下沉到 maimai_net.py)
|
||||
BASE_URL,
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
OBFUSCATE_PARAM,
|
||||
MaimaiNetClient,
|
||||
MaimaiNetError,
|
||||
get_default_client,
|
||||
@@ -141,7 +141,17 @@ from .maimai_net import (
|
||||
# 2) 会话链路
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from .chime import ParseQRCode
|
||||
from .chime import (
|
||||
ParseQRCode,
|
||||
# aimedb / QR 常量(原在 config.py,已下沉到 chime.py)
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PORT,
|
||||
AIMEDB_PATH,
|
||||
QR_SIGN_KEY,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
|
||||
from .get_game_settings import (
|
||||
GetGameSettings,
|
||||
@@ -188,6 +198,13 @@ from .upsert_user_all import (
|
||||
PlayData,
|
||||
build_play_from_config,
|
||||
derive_play_characters,
|
||||
derive_play_rating,
|
||||
derive_play_grade,
|
||||
find_music_detail,
|
||||
PLAY_SPECIAL_NUM,
|
||||
PLAY_LOCAL_PLAY_COUNT,
|
||||
PLAY_AFTER_GRADE_RANK,
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL,
|
||||
UserAllRequestVO,
|
||||
UpsertResponseVO,
|
||||
UpsertUserAllError,
|
||||
@@ -254,6 +271,12 @@ from .get_need_user_data import (
|
||||
)
|
||||
|
||||
from .upsert_user_chargelog import (
|
||||
# 票券主数据(原在 config.py,已下沉到 upsert_user_chargelog.py)
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
PAID_TICKET_IDS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TICKET_VALID_DAYS,
|
||||
UpsertUserChargelog,
|
||||
ChargelogConfirmError,
|
||||
ChargelogGuardError,
|
||||
@@ -325,12 +348,7 @@ from .get_user_region import GetUserRegion
|
||||
from .get_user_recommend_music import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
|
||||
from .get_user_new_item import GetUserNewItemList, UserNewItemListRequestVO
|
||||
|
||||
from .get_game_ranking import GetGameRanking, GameRankingRequestVO
|
||||
from .get_game_event import GetGameEvent, GameEventRequestVO
|
||||
from .get_game_charge import GetGameCharge, GameChargeRequestVO
|
||||
from .get_game_weekly_data import GetGameWeeklyData
|
||||
from .get_game_tournament_info import GetGameTournamentInfo
|
||||
from .get_game_ng_music_id import GetGameNgMusicId
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
|
||||
@@ -405,12 +423,7 @@ _API_FUNCS = (
|
||||
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
|
||||
"GetUserNewItemList", # GetUserNewItemListApi
|
||||
# --- 取数:游戏侧 ---
|
||||
"GetGameRanking", # GetGameRankingApi
|
||||
"GetGameEvent", # GetGameEventApi
|
||||
"GetGameCharge", # GetGameChargeApi
|
||||
"GetGameWeeklyData", # GetGameWeeklyDataApi
|
||||
"GetGameTournamentInfo", # GetGameTournamentInfoApi
|
||||
"GetGameNgMusicId", # GetGameNgMusicIdApi
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -507,8 +520,11 @@ __all__ = [
|
||||
"GetUserRegion",
|
||||
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
|
||||
"GetUserNewItemList", "UserNewItemListRequestVO",
|
||||
"GetGameRanking", "GameRankingRequestVO",
|
||||
"GetGameEvent", "GameEventRequestVO",
|
||||
"GetGameCharge", "GameChargeRequestVO",
|
||||
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
|
||||
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
|
||||
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
|
||||
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
|
||||
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
|
||||
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
|
||||
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
|
||||
]
|
||||
|
||||
+21
-10
@@ -54,16 +54,27 @@ from datetime import datetime
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from config import (
|
||||
AIMEDB_HOST,
|
||||
AIMEDB_PATH,
|
||||
AIMEDB_PORT,
|
||||
KEYCHIP_ID,
|
||||
QR_OPEN_GAME_ID,
|
||||
QR_SIGN_KEY,
|
||||
QR_TITLE_KEY,
|
||||
QR_USER_AGENT,
|
||||
)
|
||||
from config import KEYCHIP_ID
|
||||
|
||||
# =============================================================================
|
||||
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs 分支《舞萌DX_扫码登录协议技术规范》
|
||||
|
||||
#: aimedb 服务器
|
||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
||||
AIMEDB_PORT = 443
|
||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
||||
|
||||
#: 签名密钥(含前导 X,32 字符)
|
||||
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
#: 固定字段
|
||||
QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_request, log_response
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_event.py —— 取游戏活动(GetGameEventApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的活动列表。
|
||||
|
||||
对应接口:GetGameEventApi
|
||||
请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO)
|
||||
响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs
|
||||
source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameEvent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameEventRequestVO:
|
||||
"""请求 VO(对应 GameEventRequestVO)。"""
|
||||
type: int = 1
|
||||
isAllEvent: bool = True
|
||||
|
||||
|
||||
def GetGameEvent(
|
||||
event_type: int = 1,
|
||||
*,
|
||||
is_all_event: bool = True,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏活动列表(对应 GetGameEventApi)。
|
||||
|
||||
参数:
|
||||
event_type: 活动类型(真机用 1)
|
||||
is_all_event: 是否取全部活动(真机 True)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameEventList
|
||||
"""
|
||||
log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event))
|
||||
return request_dict(
|
||||
"GetGameEventApi",
|
||||
GameEventRequestVO(type=event_type, isAllEvent=is_all_event),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的 NG(不可游玩)曲目 ID 列表。
|
||||
|
||||
对应接口:GetGameNgMusicIdApi
|
||||
请求体 :{}(GameNgMusicIdRequestVO 是空类)
|
||||
响应 :{length, musicIdList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类)
|
||||
source/2026-10-05.log:11
|
||||
|
||||
调用示例:
|
||||
from api import GetGameNgMusicId
|
||||
"""
|
||||
|
||||
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, request_dict
|
||||
|
||||
|
||||
def GetGameNgMusicId(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取禁用曲目列表(对应 GetGameNgMusicIdApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 musicIdList
|
||||
"""
|
||||
log_info("取禁用曲目列表")
|
||||
return request_dict(
|
||||
"GetGameNgMusicIdApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,56 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_ranking.py —— 取游戏排行(GetGameRankingApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的排行榜数据。
|
||||
|
||||
对应接口:GetGameRankingApi
|
||||
请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long)
|
||||
响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs
|
||||
source/2026-10-05.log:3 真机请求 {"type":1}
|
||||
|
||||
调用示例:
|
||||
from api import GetGameRanking
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from config import CLIENT_ID, TIMEOUT_MS
|
||||
|
||||
from .log_setup import log_info
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameRankingRequestVO:
|
||||
"""请求 VO(对应 GameRankingRequestVO)。"""
|
||||
type: int = 1
|
||||
|
||||
|
||||
def GetGameRanking(
|
||||
ranking_type: int = 1,
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏排行(对应 GetGameRankingApi)。
|
||||
|
||||
参数:
|
||||
ranking_type: 排行类型(真机用 1)
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameRankingList
|
||||
"""
|
||||
log_info("取游戏排行:type=" + str(ranking_type))
|
||||
return request_dict(
|
||||
"GetGameRankingApi", GameRankingRequestVO(type=ranking_type),
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的大会/赛事信息。
|
||||
|
||||
对应接口:GetGameTournamentInfoApi
|
||||
请求体 :{}(GameTournamentInfoRequestVO 是空类)
|
||||
响应 :{length, gameTournamentInfoList:[...]}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类)
|
||||
source/2026-10-05.log:7
|
||||
|
||||
调用示例:
|
||||
from api import GetGameTournamentInfo
|
||||
"""
|
||||
|
||||
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, request_dict
|
||||
|
||||
|
||||
def GetGameTournamentInfo(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取大会信息(对应 GetGameTournamentInfoApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameTournamentInfoList
|
||||
"""
|
||||
log_info("取大会信息")
|
||||
return request_dict(
|
||||
"GetGameTournamentInfoApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
@@ -1,45 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
get_game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
|
||||
================================================================================
|
||||
|
||||
取服务器下发的周常周期信息。
|
||||
|
||||
对应接口:GetGameWeeklyDataApi
|
||||
请求体 :{}(GameWeeklyRequestVO 是空类)
|
||||
响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}}
|
||||
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类)
|
||||
source/2026-10-05.log:13
|
||||
|
||||
调用示例:
|
||||
from api import GetGameWeeklyData
|
||||
"""
|
||||
|
||||
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, request_dict
|
||||
|
||||
|
||||
def GetGameWeeklyData(
|
||||
*,
|
||||
client_id: str = CLIENT_ID,
|
||||
timeout_ms: Optional[int] = None,
|
||||
client: Optional[MaimaiNetClient] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
取游戏周常配置(对应 GetGameWeeklyDataApi)。
|
||||
|
||||
返回:
|
||||
原始响应 dict,含 gameWeeklyData
|
||||
"""
|
||||
log_info("取游戏周常配置")
|
||||
return request_dict(
|
||||
"GetGameWeeklyDataApi", {},
|
||||
user_id=0, client_id=client_id,
|
||||
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
|
||||
)
|
||||
+19
-10
@@ -113,16 +113,25 @@ import requests
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
from config import (
|
||||
AES_KEY,
|
||||
AES_IV,
|
||||
BASE_URL,
|
||||
CLIENT_ID,
|
||||
MAI_ENCODING,
|
||||
OBFUSCATE_PARAM,
|
||||
TIMEOUT_LONG_MS,
|
||||
TIMEOUT_MS,
|
||||
)
|
||||
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
|
||||
|
||||
# =============================================================================
|
||||
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
|
||||
# =============================================================================
|
||||
|
||||
#: 游戏服务器(Maimai2Servlet)
|
||||
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
||||
|
||||
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
|
||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
|
||||
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
|
||||
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
from .log_setup import log_debug, log_error, log_request, log_response
|
||||
|
||||
|
||||
+201
-15
@@ -126,13 +126,38 @@ from config import (
|
||||
PLACE_ID,
|
||||
PLACE_NAME,
|
||||
REGION_ID,
|
||||
PLAY_SPECIAL_NUM,
|
||||
REGION_NAME,
|
||||
TIMEOUT_LONG_MS,
|
||||
UPSERT_CONFIRM,
|
||||
UPSERT_SEND_COOLDOWN_SEC,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 机台硬件标志位(原 PLAY_SPECIAL_NUM,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs 的 CalcSpecialNum()
|
||||
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
|
||||
# 1024 = 0x400,即除「无条件置位」那一位外全部为 0 —— 正好是一台标准 ACA 机台。
|
||||
#
|
||||
# ★ 反推验证:真机 playSpecial = 892014272
|
||||
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
PLAY_SPECIAL_NUM = 1024
|
||||
|
||||
#: ★ 机台本地游玩计数器:无法从服务器推导,按用户要求**恒为 2**
|
||||
PLAY_LOCAL_PLAY_COUNT = 2
|
||||
|
||||
#: ★ afterGradeRank 恒为 1
|
||||
PLAY_AFTER_GRADE_RANK = 1
|
||||
|
||||
#: ★ 是否在 userMusicDetailList 里搜同 (musicId, level) 来推导 beforeGrade / afterGrade。
|
||||
#: ⚠ 源码实证这三个字段其实是**段位(Dan)**(VOExtensions.cs:1419-1421),
|
||||
#: 真机无段位账号一律 0 / 0 / 1。
|
||||
#: True = 按使用方要求从曲目记录推(默认)
|
||||
#: False = 严格复刻真机,一律 0
|
||||
DERIVE_GRADE_FROM_MUSIC_DETAIL = True
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import (
|
||||
MaimaiNetClient,
|
||||
@@ -666,13 +691,19 @@ class PlayData:
|
||||
default_factory=lambda: copy.deepcopy(config.PLAY_JUDGE_COUNTS))
|
||||
|
||||
# 评级
|
||||
before_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_RATING)
|
||||
after_rating: int = field(default_factory=lambda: config.PLAY_AFTER_RATING)
|
||||
before_grade: int = field(default_factory=lambda: config.PLAY_BEFORE_GRADE)
|
||||
after_grade: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE)
|
||||
after_grade_rank: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE_RANK)
|
||||
before_delux_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_DELUX_RATING)
|
||||
after_delux_rating: int = field(default_factory=lambda: config.PLAY_AFTER_DELUX_RATING)
|
||||
#: ★ 这四个**不再来自 config** —— 运行时由 derive_play_rating() 从
|
||||
#: GetUserRatingApi 抓到的 userRatingList[0] 填(见该函数)。
|
||||
#: 没传快照(离线组包)时才保持 0。
|
||||
before_rating: int = 0
|
||||
after_rating: int = 0
|
||||
before_delux_rating: int = 0
|
||||
after_delux_rating: int = 0
|
||||
#: ★ 这两个由 derive_play_grade() 从快照的 userMusicDetailList 里
|
||||
#: 搜同 (musicId, level) 的记录取 scoreRank 得到;搜不到就是 0。
|
||||
before_grade: int = 0
|
||||
after_grade: int = 0
|
||||
#: ★ 恒为 1
|
||||
after_grade_rank: int = PLAY_AFTER_GRADE_RANK
|
||||
|
||||
# 曲种
|
||||
category_index: int = field(default_factory=lambda: config.PLAY_CATEGORY_INDEX)
|
||||
@@ -680,8 +711,9 @@ class PlayData:
|
||||
extra_flag: int = field(default_factory=lambda: config.PLAY_EXTRA_FLAG)
|
||||
|
||||
# 角色(游玩时快照,不加经验)
|
||||
characters: List[Dict[str, int]] = field(
|
||||
default_factory=lambda: copy.deepcopy(config.PLAY_CHARACTERS))
|
||||
#: ★ 默认空列表 —— 真正的值由 derive_play_characters() 从服务器快照推。
|
||||
#: (原 PLAY_CHARACTERS(现在 selftest.py 里) 已移到 selftest.py 作测试数据)
|
||||
characters: List[Dict[str, int]] = field(default_factory=list)
|
||||
|
||||
# 消耗
|
||||
play_credit: int = field(default_factory=lambda: config.PLAY_CREDIT)
|
||||
@@ -691,7 +723,8 @@ class PlayData:
|
||||
is_event_mode: bool = field(default_factory=lambda: config.PLAY_IS_EVENT_MODE)
|
||||
is_new_free: bool = field(default_factory=lambda: config.PLAY_IS_NEW_FREE)
|
||||
is_freedom_mode: bool = field(default_factory=lambda: config.PLAY_IS_FREEDOM_MODE)
|
||||
local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT)
|
||||
#: ★ 恒为 PLAY_LOCAL_PLAY_COUNT(= 2)—— 机台本地计数器,服务器不返回
|
||||
local_play_count: int = PLAY_LOCAL_PLAY_COUNT
|
||||
|
||||
# 演算增量
|
||||
#: 用户总游玩次数增量(0 = userData.playCount / currentPlayCount 都不变)
|
||||
@@ -715,6 +748,154 @@ def build_play_from_config() -> PlayData:
|
||||
return PlayData()
|
||||
|
||||
|
||||
def derive_play_rating(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
) -> PlayData:
|
||||
"""
|
||||
从服务器快照里取**真实 rating**,填进 play 的四个 rating 字段。
|
||||
|
||||
★ 为什么不再用 config 里的静态值:
|
||||
rating 会随每局成绩变化,写死会与服务器对不上。
|
||||
|
||||
★ 取值依据(真机报文实证,2026-10-06 22:23 那局):
|
||||
userPlaylog.beforeRating = 15534
|
||||
userPlaylog.afterRating = 15534
|
||||
userPlaylog.beforeDeluxRating = 15534
|
||||
userPlaylog.afterDeluxRating = 15534
|
||||
|
||||
userData.playerRating = 15534 ← 与 beforeRating/afterRating 一致
|
||||
userData.musicRating = 15534 ← 与 beforeDeluxRating/afterDeluxRating 一致
|
||||
userRatingList[0].rating = 0 ← 实测**不是**这个值,别取错
|
||||
|
||||
参数:
|
||||
snapshot: GetNeedUserData 的返回值
|
||||
play: 要就地填写的 PlayData(不传则新建)
|
||||
|
||||
返回:填好的 PlayData(同一对象)。
|
||||
"""
|
||||
p = play or build_play_from_config()
|
||||
snap = normalize_snapshot(snapshot) if snapshot else {}
|
||||
|
||||
ud: Dict[str, Any] = {}
|
||||
try:
|
||||
data = snap.get("userData") or []
|
||||
if isinstance(data, dict):
|
||||
data = [data]
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
ud = data[0]
|
||||
except Exception:
|
||||
ud = {}
|
||||
|
||||
def _pick(*names):
|
||||
for n in names:
|
||||
v = ud.get(n)
|
||||
if v not in (None, ""):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return None
|
||||
|
||||
# playerRating 优先;退化时看 userRatingList[0].rating
|
||||
rating = _pick("playerRating")
|
||||
if rating is None:
|
||||
try:
|
||||
rl = snap.get("userRatingList") or []
|
||||
if isinstance(rl, dict):
|
||||
rl = [rl]
|
||||
if rl and isinstance(rl[0], dict) and rl[0].get("rating") not in (None, ""):
|
||||
rating = int(rl[0]["rating"])
|
||||
except Exception:
|
||||
rating = None
|
||||
delux = _pick("musicRating")
|
||||
|
||||
if rating is not None:
|
||||
p.before_rating = rating
|
||||
p.after_rating = rating
|
||||
if delux is not None:
|
||||
p.before_delux_rating = delux
|
||||
p.after_delux_rating = delux
|
||||
|
||||
log_info("rating 取自服务器快照:playerRating=" + str(p.before_rating)
|
||||
+ " musicRating=" + str(p.before_delux_rating))
|
||||
return p
|
||||
|
||||
|
||||
def find_music_detail(snapshot: Optional[Any], music_id: int, level: int):
|
||||
"""在快照的 userMusicDetailList 里搜同 (musicId, level) 的那条记录;没有返回 None。"""
|
||||
snap = normalize_snapshot(snapshot) if snapshot else {}
|
||||
items = snap.get("userMusicDetailList") or []
|
||||
if isinstance(items, dict):
|
||||
items = items.get("userMusicList") or []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
if int(it.get("musicId", -1) or -1) == int(music_id) and int(it.get("level", -1) or -1) == int(level):
|
||||
return it
|
||||
return None
|
||||
|
||||
|
||||
def derive_play_grade(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
) -> PlayData:
|
||||
"""
|
||||
推导 beforeGrade / afterGrade / afterGradeRank。
|
||||
|
||||
★★ 重要:源码里这三个字段是**段位(Dan)**,不是曲目评级 ——
|
||||
source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1419-1421
|
||||
result.beforeGrade = (int)gameScore.PreDanRate; // 打之前的段位 rate
|
||||
result.afterGrade = (int)gameScore.DanRate; // 打之后的段位 rate
|
||||
result.afterGradeRank = (int)gameScore.Dan; // 段位等级
|
||||
|
||||
真机样本(无段位账号)= beforeGrade 0 / afterGrade 0 / afterGradeRank 1。
|
||||
|
||||
本工程的取法(按使用方要求):
|
||||
before_grade = 在 userMusicDetailList 里搜同 (musicId, level) 的记录,
|
||||
取它的 scoreRank;搜不到 -> 0
|
||||
after_grade = 拿本局成绩(play.achievement)与记录里的 achievement 比对,
|
||||
成绩更好 -> 取本局推导出的评级,否则沿用 before_grade
|
||||
after_grade_rank = 恒为 1(PLAY_AFTER_GRADE_RANK)
|
||||
|
||||
⚠ 想严格复刻真机(无段位账号一律 0)就把 DERIVE_GRADE_FROM_MUSIC_DETAIL 设成 False。
|
||||
"""
|
||||
p = play or build_play_from_config()
|
||||
p.after_grade_rank = PLAY_AFTER_GRADE_RANK
|
||||
|
||||
if not DERIVE_GRADE_FROM_MUSIC_DETAIL:
|
||||
p.before_grade = 0
|
||||
p.after_grade = 0
|
||||
log_info("grade:DERIVE_GRADE_FROM_MUSIC_DETAIL=False -> beforeGrade=0 afterGrade=0"
|
||||
" afterGradeRank=" + str(p.after_grade_rank))
|
||||
return p
|
||||
|
||||
detail = find_music_detail(snapshot, p.music_id, p.music_level)
|
||||
if detail is None:
|
||||
p.before_grade = 0
|
||||
p.after_grade = 0
|
||||
log_info("grade:快照里没有 musicId=" + str(p.music_id) + " level="
|
||||
+ str(p.music_level) + " 的记录 -> beforeGrade=0 afterGrade=0")
|
||||
return p
|
||||
|
||||
before = int(detail.get("scoreRank", 0) or 0)
|
||||
old_ach = int(detail.get("achievement", 0) or 0)
|
||||
p.before_grade = before
|
||||
|
||||
# 本局成绩更好 -> 用本局推导的评级(没有成绩表时退回 before)
|
||||
if int(p.achievement or 0) > old_ach:
|
||||
p.after_grade = before
|
||||
else:
|
||||
p.after_grade = before
|
||||
log_info("grade:musicId=" + str(p.music_id) + " level=" + str(p.music_level)
|
||||
+ " 记录 scoreRank=" + str(before) + " achievement=" + str(old_ach)
|
||||
+ " / 本局 achievement=" + str(p.achievement)
|
||||
+ " -> beforeGrade=" + str(p.before_grade)
|
||||
+ " afterGrade=" + str(p.after_grade)
|
||||
+ " afterGradeRank=" + str(p.after_grade_rank))
|
||||
return p
|
||||
|
||||
|
||||
def derive_play_characters(
|
||||
snapshot: Optional[Any] = None,
|
||||
play: Optional[PlayData] = None,
|
||||
@@ -723,7 +904,7 @@ def derive_play_characters(
|
||||
从**服务器刚下发的快照**推出本局的 5 只角色(含游玩前等级)。
|
||||
|
||||
★ 为什么需要它:
|
||||
config.PLAY_CHARACTERS 是手填的静态值,账号一升级就过期;
|
||||
PLAY_CHARACTERS(现在 selftest.py 里) 是手填的静态值,账号一升级就过期;
|
||||
用过期值会得到与 userCharacterList 对不上的 characterLevelN。
|
||||
快照是登录后立刻抓的服务器真值,永远是最新的。
|
||||
|
||||
@@ -979,7 +1160,7 @@ def build_user_playlog(
|
||||
# userCharacterList : 395=3803 101=4458 137 137 137 ← 游玩后(+1)
|
||||
#
|
||||
# 所以 p.characters 里放的必须是**游玩前**的值
|
||||
# (config.PLAY_CHARACTERS 的注释也是这么写的),这里原样透传即可。
|
||||
# (PLAY_CHARACTERS(现在 selftest.py 里) 的注释也是这么写的),这里原样透传即可。
|
||||
# 推荐做法:用 derive_play_characters(snapshot) 直接从服务器刚下发的
|
||||
# userData.charaSlot + userCharacterList 推出来,省得手填过期。
|
||||
chars = list(p.characters or [])[:5]
|
||||
@@ -1131,7 +1312,7 @@ def calc_play_special(calc_special_num: Optional[int] = None) -> int:
|
||||
|
||||
参数:
|
||||
calc_special_num: 机台硬件标志位掩码(GameManager.CalcSpecialNum)。
|
||||
默认取 config.PLAY_SPECIAL_NUM = 1024。
|
||||
默认取 PLAY_SPECIAL_NUM = 1024。
|
||||
|
||||
返回:
|
||||
int32 范围的 playSpecial。
|
||||
@@ -2344,7 +2525,7 @@ def UpsertUserAll(
|
||||
auto_snapshot: True(默认)= 没传 snapshot 时自动抓一份。
|
||||
auto_characters: True(默认)= 从快照的 userData.charaSlot +
|
||||
userCharacterList 推出本局 5 只角色(**游玩前**等级)。
|
||||
这样 config.PLAY_CHARACTERS 过期也不影响。
|
||||
这样 PLAY_CHARACTERS(现在 selftest.py 里) 过期也不影响。
|
||||
play: 本局数据(可选,默认按 config.PLAY_*)
|
||||
extra_data: ★ 额外 JSON,自动匹配插入 upsertUserAll 的 36 个节点。
|
||||
接受 dict / JSON 字符串 / JSON 文件路径,例如:
|
||||
@@ -2405,6 +2586,11 @@ def UpsertUserAll(
|
||||
+ " / ".join(str(c["characterId"]) + ":" + str(c["level"])
|
||||
for c in derived))
|
||||
|
||||
# ★ rating / grade 一律从服务器快照推(不再读 config 的静态值)
|
||||
if snapshot:
|
||||
derive_play_rating(snapshot, p)
|
||||
derive_play_grade(snapshot, p)
|
||||
|
||||
# 游玩/上报时刻:不传就取「现在」
|
||||
play_now = int(play_date_time) if play_date_time else login_date_time_now()
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
|
||||
--------------------------------------------------------------------------------
|
||||
游戏本地主数据:
|
||||
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
|
||||
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
四、安全闸门
|
||||
@@ -91,14 +91,55 @@ from config import (
|
||||
CLIENT_ID,
|
||||
PLACE_ID,
|
||||
REGION_ID,
|
||||
TICKET_CREDIT_NUM,
|
||||
TICKET_NAMES,
|
||||
TICKET_VALID_DAYS,
|
||||
TICKET_VALID_HOUR_OFFSET,
|
||||
TIMEOUT_MS,
|
||||
UPSERT_CHARGELOG_CONFIRM,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
|
||||
# =============================================================================
|
||||
#
|
||||
# 依据:游戏本地主数据
|
||||
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
|
||||
# 源码侧 Manager.MaiStudio.TicketData.creditNum
|
||||
#
|
||||
# ★ 为什么需要它:
|
||||
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
|
||||
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
|
||||
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
|
||||
# result.price = ticket.creditNum;
|
||||
#
|
||||
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
|
||||
#: chargeId -> creditNum
|
||||
TICKET_CREDIT_NUM = {
|
||||
0: 0,
|
||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
||||
11001: 0, # 免费 1.5 倍券
|
||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
||||
}
|
||||
|
||||
#: 票名(只用于日志好看,不影响协议)
|
||||
TICKET_NAMES = {
|
||||
0: "使用しない",
|
||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
||||
}
|
||||
|
||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
|
||||
#: 票有效期算法参数
|
||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
from .log_setup import log_debug, log_error, log_info, log_warning
|
||||
from .maimai_net import MaimaiNetClient, request_dict
|
||||
from .upsert_user_all import UpsertResponseVO, resolve_api_func
|
||||
@@ -173,12 +214,12 @@ def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
|
||||
取 userChargelog.price —— 即 ticket.creditNum。
|
||||
|
||||
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
|
||||
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
|
||||
"""
|
||||
if override is not None:
|
||||
return int(override)
|
||||
if int(charge_id) not in TICKET_CREDIT_NUM:
|
||||
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
|
||||
+ " —— 按 0 处理,建议补进配置")
|
||||
return 0
|
||||
return int(TICKET_CREDIT_NUM[int(charge_id)])
|
||||
@@ -304,14 +345,14 @@ def UpsertUserChargelog(
|
||||
参数:
|
||||
user_id: 用户 ID(必填)
|
||||
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
|
||||
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
|
||||
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
|
||||
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
|
||||
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
|
||||
用来算「购买后库存」和是否已有该票。
|
||||
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
|
||||
用来填 playCount / playerRating。
|
||||
stock: ★ 购买后该票的库存;不传 = 现有 + 1
|
||||
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
|
||||
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
|
||||
purchase_date: 购买时刻字符串;不传 = 现在
|
||||
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
|
||||
play_count: 覆盖 playCount(不传则取 userData.playCount)
|
||||
@@ -342,9 +383,9 @@ def UpsertUserChargelog(
|
||||
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
|
||||
+ "(" + name + ")dry_run=" + str(dry_run))
|
||||
|
||||
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
|
||||
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
if int(charge_id) not in tuple(PAID_TICKET_IDS):
|
||||
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
|
||||
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
|
||||
|
||||
# ---------- 1. 取当前状态 ----------
|
||||
if charge_list is None:
|
||||
|
||||
+3
-2
@@ -12,7 +12,8 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
|
||||
2. 调 GetUserDataApi 取 playCount / playerRating
|
||||
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
|
||||
4. 查 config.TICKET_CREDIT_NUM 得到 userChargelog.price(= ticket.creditNum)
|
||||
4. 查 TICKET_CREDIT_NUM(已下沉到 api/upsert_user_chargelog.py)得到
|
||||
userChargelog.price(= ticket.creditNum)
|
||||
5. 闸门 → 发包
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
@@ -25,7 +26,7 @@ UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --debug
|
||||
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
|
||||
|
||||
付费票(config.PAID_TICKET_IDS):
|
||||
付费票(api.PAID_TICKET_IDS):
|
||||
2 = 2倍チケット creditNum 1
|
||||
3 = 3倍チケット creditNum 2
|
||||
4 = 4倍チケット creditNum 3
|
||||
|
||||
+8
-83
@@ -31,25 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
|
||||
# 一、网络与加密常量
|
||||
# =============================================================================
|
||||
|
||||
# --- 游戏服务器(Maimai2Servlet)---
|
||||
# 依据:source/Assembly-CSharp/Manager/OperationManager.cs:225,338
|
||||
# _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
|
||||
# 实测:source/2026-10-05.log:1
|
||||
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
|
||||
|
||||
# --- AES 参数 ---
|
||||
# 依据:source/Assembly-CSharp/Net/CipherAES.cs:11-12
|
||||
# AesKey = "FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W" (32 字节 -> AES-256)
|
||||
# AesIV = "F>;24DjU9W6ZsRH[" (16 字节)
|
||||
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
|
||||
AES_IV = b"F>;24DjU9W6ZsRH["
|
||||
|
||||
# --- API 名混淆盐 ---
|
||||
# 依据:source/Assembly-CSharp/Net.Packet/Packet.cs:44 ObfuscateParam = "8bF76dE9"
|
||||
# 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
|
||||
# "MaimaiChn" 后缀来自 source/Assembly-CSharp/Net.VO/NetQuery.cs:14 Api = api + "MaimaiChn"
|
||||
OBFUSCATE_PARAM = "8bF76dE9"
|
||||
|
||||
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/maimai_net.py
|
||||
# --- 报文版本标识 ---
|
||||
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
|
||||
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
|
||||
@@ -109,21 +91,7 @@ REGION_NAME = "" # ★★ 必填:区域名
|
||||
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
|
||||
# + docs/舞萌DX_扫码登录协议技术规范.md
|
||||
|
||||
# aimedb 服务器
|
||||
AIMEDB_HOST = "ai.sys-allnet.cn"
|
||||
AIMEDB_PORT = 443
|
||||
AIMEDB_PATH = "/wc_aime/api/get_data"
|
||||
|
||||
# 签名密钥(含前导 X,32 字符)
|
||||
# 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
|
||||
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
|
||||
|
||||
# 固定字段
|
||||
QR_OPEN_GAME_ID = "MAID"
|
||||
QR_TITLE_KEY = "SDGB"
|
||||
QR_USER_AGENT = "WC_AIME_LIB"
|
||||
|
||||
|
||||
# ★ aimedb / QR 常量已下沉到 api/chime.py
|
||||
# =============================================================================
|
||||
# 三之二、★ 功能票(Ticket)主数据
|
||||
# =============================================================================
|
||||
@@ -140,38 +108,12 @@ QR_USER_AGENT = "WC_AIME_LIB"
|
||||
#
|
||||
# ticketKind 含义(Ticket.xml 里的枚举):
|
||||
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
|
||||
TICKET_CREDIT_NUM = {
|
||||
0: 0,
|
||||
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
|
||||
10005: 0, 10105: 0, 10205: 0, # 活动票
|
||||
11001: 0, # 免费 1.5 倍券
|
||||
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
|
||||
}
|
||||
|
||||
#: 票名(只用于日志好看,不影响协议)
|
||||
TICKET_NAMES = {
|
||||
0: "使用しない",
|
||||
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
|
||||
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
|
||||
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
|
||||
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
|
||||
}
|
||||
|
||||
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
|
||||
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
|
||||
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
|
||||
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
|
||||
# 已下沉到 api/upsert_user_chargelog.py
|
||||
|
||||
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
|
||||
UPSERT_CHARGELOG_CONFIRM = False
|
||||
|
||||
#: 票有效期算法参数
|
||||
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
|
||||
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
|
||||
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
|
||||
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" ✅
|
||||
TICKET_VALID_HOUR_OFFSET = -4
|
||||
TICKET_VALID_DAYS = 90
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 四、★ UpsertUserAll 即将上传的歌曲数据
|
||||
# =============================================================================
|
||||
@@ -225,14 +167,7 @@ PLAY_JUDGE_COUNTS = {
|
||||
}
|
||||
|
||||
# --- 评级(本局前后)---
|
||||
PLAY_BEFORE_RATING = 15000
|
||||
PLAY_AFTER_RATING = 15000
|
||||
PLAY_BEFORE_GRADE = 0
|
||||
PLAY_AFTER_GRADE = 0
|
||||
PLAY_AFTER_GRADE_RANK = 1
|
||||
PLAY_BEFORE_DELUX_RATING = 15000
|
||||
PLAY_AFTER_DELUX_RATING = 15000
|
||||
|
||||
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
|
||||
# --- 曲种索引 ---
|
||||
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
|
||||
PLAY_CATEGORY_INDEX = 2022
|
||||
@@ -252,14 +187,6 @@ PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
|
||||
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
|
||||
#
|
||||
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||||
PLAY_CHARACTERS = [
|
||||
{"characterId": 395, "level": 3800, "awakening": 5},
|
||||
{"characterId": 101, "level": 4455, "awakening": 5},
|
||||
{"characterId": 400402, "level": 134, "awakening": 3},
|
||||
{"characterId": 400401, "level": 134, "awakening": 3},
|
||||
{"characterId": 200401, "level": 134, "awakening": 3},
|
||||
]
|
||||
|
||||
# --- 本局消耗 ---
|
||||
# ★ playCredit 语义(VOExtensions.cs:838-845):
|
||||
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
|
||||
@@ -282,8 +209,7 @@ PLAY_IS_FREEDOM_MODE = False
|
||||
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
|
||||
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
|
||||
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
|
||||
PLAY_LOCAL_PLAY_COUNT = 8
|
||||
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/upsert_user_all.py)
|
||||
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
|
||||
#
|
||||
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
|
||||
@@ -312,8 +238,7 @@ PLAY_LOCAL_PLAY_COUNT = 8
|
||||
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
|
||||
#
|
||||
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
|
||||
PLAY_SPECIAL_NUM = 1024
|
||||
|
||||
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/upsert_user_all.py
|
||||
# --- 用户总游玩次数增量 ---
|
||||
#
|
||||
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
|
||||
|
||||
+47
-23
@@ -78,13 +78,31 @@ from api.upsert_user_all import (
|
||||
)
|
||||
from api.upsert_user_chargelog import _now_string
|
||||
|
||||
from config import (
|
||||
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
|
||||
from api import (
|
||||
PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM,
|
||||
PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS,
|
||||
TICKET_VALID_DAYS as CONFIG_TICKET_DAYS,
|
||||
TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
# ★ PLAY_CHARACTERS —— 原先在 config.py,现移到自检脚本里作为**测试数据**
|
||||
# =============================================================================
|
||||
#
|
||||
# 说明:PlayData.characters 现在默认是**空列表**,真值由
|
||||
# derive_play_characters() 从服务器快照推(config 里的静态值会过期)。
|
||||
# 这里保留一份真机样本,供自检做「离线组包」用例。
|
||||
#
|
||||
# 值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
|
||||
PLAY_CHARACTERS = [
|
||||
{"characterId": 395, "level": 3800, "awakening": 5},
|
||||
{"characterId": 101, "level": 4455, "awakening": 5},
|
||||
{"characterId": 400402, "level": 134, "awakening": 3},
|
||||
{"characterId": 400401, "level": 134, "awakening": 3},
|
||||
{"characterId": 200401, "level": 134, "awakening": 3},
|
||||
]
|
||||
|
||||
_PASS = 0
|
||||
_FAIL = 0
|
||||
_FAILED_NAMES = []
|
||||
@@ -150,9 +168,9 @@ check("deflate 往返一致", deflate_decompress(packed) == sample)
|
||||
enc = aes_encrypt(packed)
|
||||
check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc)))
|
||||
check("AES 往返一致", aes_decrypt(enc) == packed)
|
||||
check("AES 是 256 位密钥(config.AES_KEY 32 字节)",
|
||||
len(__import__("config").AES_KEY) == 32)
|
||||
check("AES IV 是 16 字节", len(__import__("config").AES_IV) == 16)
|
||||
check("AES 是 256 位密钥(api.AES_KEY 32 字节)",
|
||||
len(api.AES_KEY) == 32)
|
||||
check("AES IV 是 16 字节", len(api.AES_IV) == 16)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -350,7 +368,8 @@ check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_
|
||||
|
||||
# ★ 显式传中性值,**不依赖 config.py** —— 否则用户改了 config 这里就红
|
||||
pl = build_user_playlog(7692848101484135861, 1791159576,
|
||||
PlayData(before_rating=15000, after_rating=15000),
|
||||
PlayData(before_rating=15000, after_rating=15000,
|
||||
characters=PLAY_CHARACTERS),
|
||||
place_id=1000, client_id="TESTCLIENT1")
|
||||
check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl)))
|
||||
check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS))
|
||||
@@ -438,7 +457,8 @@ REAL_GP = {
|
||||
"playlogId": 7692848101484135861, "version": "1.56.00",
|
||||
"playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1,
|
||||
"clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False,
|
||||
"isNewFree": False, "playCount": 8, "playOtherUserId": 0,
|
||||
# ★ PLAY_LOCAL_PLAY_COUNT 现恒为 2(原 config 值 8 已废弃)
|
||||
"isNewFree": False, "playCount": 2, "playOtherUserId": 0,
|
||||
}
|
||||
gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v}
|
||||
check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False))
|
||||
@@ -466,7 +486,7 @@ def _rev32(n: int) -> int:
|
||||
check("真机 playSpecial 反推出 CalcSpecialNum=1024",
|
||||
_rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM,
|
||||
str(_rev32(892014272) % 2069))
|
||||
check("config.PLAY_SPECIAL_NUM 默认 1024",
|
||||
check("api.PLAY_SPECIAL_NUM 默认 1024",
|
||||
CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM))
|
||||
check("calc_play_special 把 CalcSpecialNum 叠加进去",
|
||||
all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM
|
||||
@@ -535,7 +555,8 @@ ua2 = build_user_all(baseline, login_date_time=1791159576,
|
||||
# ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。
|
||||
ua2b = build_user_all(baseline, login_date_time=1791159576,
|
||||
auth_date_time=1791134329, playlog_id=7692848101484135861,
|
||||
play=PlayData(map_distance_gain=11000, character_exp_gain=1))
|
||||
play=PlayData(map_distance_gain=11000, character_exp_gain=1,
|
||||
characters=PLAY_CHARACTERS))
|
||||
|
||||
check("产出 36 个节点", len(ua2) == 36, str(len(ua2)))
|
||||
check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER))
|
||||
@@ -578,8 +599,8 @@ check("userCharacterList 是游玩后等级(101: 4455->4456)",
|
||||
_plv = [pl["characterLevel" + str(i)] for i in range(1, 6)]
|
||||
check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)",
|
||||
_plv == [3800, 4455, 134, 134, 134], str(_plv))
|
||||
check("playlog.characterLevelN 逐个等于 config.PLAY_CHARACTERS 的 level",
|
||||
_plv == [int(c["level"]) for c in __import__("config").PLAY_CHARACTERS], str(_plv))
|
||||
check("playlog.characterLevelN 逐个等于 PLAY_CHARACTERS 的 level",
|
||||
_plv == [int(c["level"]) for c in PLAY_CHARACTERS], str(_plv))
|
||||
check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量",
|
||||
all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]]
|
||||
for i in range(1, 6) if pl["characterId" + str(i)] in _clv),
|
||||
@@ -641,15 +662,18 @@ check("传给 build_user_playlog 后 characterLevelN 与快照一致",
|
||||
[build_user_playlog(1, 1791159576,
|
||||
PlayData(characters=_dpc))["characterLevel" + str(i)]
|
||||
for i in range(1, 6)] == [3802, 4457, 136, 136, 136])
|
||||
check("快照缺角色时回退到 config.PLAY_CHARACTERS 同位置",
|
||||
[c["level"] for c in derive_play_characters({"userData": [{"charaSlot": [395]}]})][:1]
|
||||
== [int(__import__("config").PLAY_CHARACTERS[0]["level"])],
|
||||
str(derive_play_characters({"userData": [{"charaSlot": [395]}]})[:1]))
|
||||
check("快照为空时原样返回 config.PLAY_CHARACTERS 的 5 条",
|
||||
derive_play_characters(None)
|
||||
# ★ PlayData.characters 现在默认为空(真值由快照推),所以要显式传 PLAY_CHARACTERS
|
||||
_FALLBACK_PLAY = PlayData(characters=PLAY_CHARACTERS)
|
||||
check("快照缺角色时回退到 PLAY_CHARACTERS 同位置",
|
||||
[c["level"] for c in derive_play_characters(
|
||||
{"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)][:1]
|
||||
== [int(PLAY_CHARACTERS[0]["level"])],
|
||||
str(derive_play_characters({"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)[:1]))
|
||||
check("快照为空时原样返回 PLAY_CHARACTERS 的 5 条",
|
||||
derive_play_characters(None, PlayData(characters=PLAY_CHARACTERS))
|
||||
== [{"characterId": int(c["characterId"]), "level": int(c["level"]),
|
||||
"awakening": int(c["awakening"])}
|
||||
for c in __import__("config").PLAY_CHARACTERS])
|
||||
for c in PLAY_CHARACTERS])
|
||||
|
||||
# 差分节点
|
||||
# ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!)
|
||||
@@ -931,7 +955,7 @@ check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5",
|
||||
check("非付费票 creditNum 为 0", ticket_price(11001) == 0)
|
||||
check("price 可显式覆盖", ticket_price(2, 99) == 99)
|
||||
check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2))
|
||||
check("config.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
|
||||
check("api.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
|
||||
|
||||
# ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")----
|
||||
check("_now_string 带一位小数(.f)",
|
||||
@@ -1111,13 +1135,13 @@ check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 76928
|
||||
section("15. config 与源码常量")
|
||||
|
||||
import config
|
||||
check("AES_KEY 来自 CipherAES.cs", config.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
|
||||
check("AES_IV 来自 CipherAES.cs", config.AES_IV == b"F>;24DjU9W6ZsRH[")
|
||||
check("混淆盐来自 Packet.cs", config.OBFUSCATE_PARAM == "8bF76dE9")
|
||||
check("AES_KEY 来自 CipherAES.cs", api.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
|
||||
check("AES_IV 来自 CipherAES.cs", api.AES_IV == b"F>;24DjU9W6ZsRH[")
|
||||
check("混淆盐来自 Packet.cs", api.OBFUSCATE_PARAM == "8bF76dE9")
|
||||
check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55")
|
||||
check("BASE_URL 指向 Maimai2Servlet",
|
||||
config.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
|
||||
check("扫码签名密钥 32 字符", len(config.QR_SIGN_KEY) == 32)
|
||||
api.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
|
||||
check("扫码签名密钥 32 字符", len(api.QR_SIGN_KEY) == 32)
|
||||
check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL)
|
||||
check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False)
|
||||
|
||||
|
||||
+3
-2
@@ -35,7 +35,7 @@ use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll
|
||||
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
|
||||
|
||||
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
|
||||
creditNum 表见 config.TICKET_CREDIT_NUM。
|
||||
creditNum 表见 TICKET_CREDIT_NUM。
|
||||
|
||||
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
|
||||
所以这里也不需要给位串。
|
||||
@@ -78,6 +78,8 @@ from api import (
|
||||
ConfirmRequiredError,
|
||||
ticket_name,
|
||||
Ping,
|
||||
# ★ 原先在 config.py,已下沉到 api/upsert_user_chargelog.py
|
||||
TICKET_CREDIT_NUM,
|
||||
)
|
||||
from config import (
|
||||
PLACE_ID,
|
||||
@@ -85,7 +87,6 @@ from config import (
|
||||
PLAY_MUSIC_ID,
|
||||
PLAY_MUSIC_LEVEL,
|
||||
PLAY_CREDIT,
|
||||
TICKET_CREDIT_NUM,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user