Files
Astral/use_ticket.py
T
Txiaochen 8e3bd6fcaa 重构:Get* 命名统一 + 用户数据落盘 + use_ticket / reincarnate
1) 命名统一(服务端 API 名带 Get 的,脚本名与函数名一律带 get/Get)
   - 26 个 api 脚本改名:userdata.py -> get_user_data.py 等
   - 7 个 game_*.py -> get_game_*.py
   - 38 个函数改名:UserData -> GetUserData 等
   - upsert_snapshot.py -> get_need_user_data.py,
     fetch_upsert_snapshot -> GetNeedUserData
   - user_getter_common.fetch_all_pages -> get_all_pages

2) GetNeedUserData 抓到的数据自动落盘
   - <工程根>/data/<userId>.json,新数据总是覆盖老数据
   - 新增 save_user_data / load_user_data / user_data_path /
     list_saved_user_ids / DATA_DIR / PROJECT_ROOT
   - data/ 已加入 .gitignore

3) use_ticket.py(新)
   - 读取账号当前持有的功能票,用 UpsertUserAll 消耗掉
   - 依据:GetUserChargeApi 与 UpsertUserAllApi 的 userChargeList 格式相同;
     真机实证 userChargeList.stock -1 + useTicketId + playCredit
   - playCredit = 投币数 + 票的 creditNum(VOExtensions.cs:838-845)

4) reincarnate.py(新)
   - 账号数据迁移(转生):A 的数据克隆到 B
   - 每包最多 4 首(真机一局上限),包间用续关登录换新 loginId
     续关依据:ContinueProcess.cs:403 -> PacketUserLogin(isContinue: true)
   - playlog 从曲目记录反推合成(没有对应的 Get 接口)

自检 238 项全绿。
2026-10-06 23:32:09 +08:00

287 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
================================================================================
一条完整的「用票」链路:
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
--------------------------------------------------------------------------------
★ 为什么可以用 UpsertUserAll 消耗票券
--------------------------------------------------------------------------------
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
服务端就会认为这张票被这一局用掉了。
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
userChargeList: [
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
{"chargeId":3,"stock":0,...},
{"chargeId":5,"stock":0,...}
]
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
creditNum 表见 config.TICKET_CREDIT_NUM。
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
所以这里也不需要给位串。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python use_ticket.py <二维码> # 预检:只看报文,不发包
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
python use_ticket.py <二维码> --send --music-id 143 --level 4
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetUserCharge,
GetNeedUserData,
summarize_snapshot,
UpsertUserAll,
PlayData,
ConfirmRequiredError,
ticket_name,
Ping,
)
from config import (
PLACE_ID,
REGION_ID,
PLAY_MUSIC_ID,
PLAY_MUSIC_LEVEL,
PLAY_CREDIT,
TICKET_CREDIT_NUM,
)
def read_charges(user_id, label):
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return lst, held
def parse_args(argv):
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
"debug": False, "skip_entry": False,
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--ticket-id" and i + 1 < len(argv):
opts["ticket_id"] = int(argv[i + 1])
i += 1
elif a == "--music-id" and i + 1 < len(argv):
opts["music_id"] = int(argv[i + 1])
i += 1
elif a == "--level" and i + 1 < len(argv):
opts["level"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("use_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 心跳:决定后续所有请求的 number 头 ----
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 读当前持有的功能券 ----
all_charges, held = read_charges(user_id, "消耗前")
if opts["list"]:
log_info("--list:只列票券,不做任何写操作")
return 0
if not held:
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
return 4
# ---- 选定目标票 ----
target = None
if opts["ticket_id"] is not None:
for c in held:
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
target = c
break
if target is None:
log_error("指定的 chargeId=" + str(opts["ticket_id"])
+ " 当前并不持有;持有的有:"
+ ", ".join(str(c.get("chargeId")) for c in held))
return 4
else:
target = held[0]
ticket_id = int(target.get("chargeId"))
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
play_credit = int(PLAY_CREDIT) + credit_num
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
+ " stock=" + str(target.get("stock"))
+ " creditNum=" + str(credit_num)
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
+ "=" + str(play_credit))
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
new_stock = int(target.get("stock", 0) or 0) - 1
if new_stock < 0:
new_stock = 0
new_list = []
for c in all_charges:
if not isinstance(c, dict):
continue
item = dict(c)
if int(item.get("chargeId", -1)) == ticket_id:
item["stock"] = new_stock
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
new_list.append(item)
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
snapshot = None
if not opts["skip_entry"]:
snapshot = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snapshot)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snapshot.get("_failed") or [])) + " 个")
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
# / userData.lastPlayCredit
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
play = PlayData(
music_id=opts["music_id"],
music_level=opts["level"],
use_ticket_id=ticket_id,
play_credit=play_credit,
)
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=snapshot,
play=play,
extra_data={"userChargeList": new_list},
dry_run=not opts["send"],
confirm=opts["send"],
verify_landing=bool(opts["send"]),
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★ 复查:票券 stock 应该减 1 ----
_, held_after = read_charges(user_id, "消耗后")
if not opts["send"]:
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
return 0
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
if still:
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
return 6
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())