read this on Github: https://github.com/Fat-pig-Cui/misc-code/tree/main/doc
本专栏标题参考了雀魂吧小吧主@甜甜cbstt的一个帖子:
https://tieba.baidu.com/p/8404725941
撰写本专栏的契机也是这个. 本专栏也会讲解这个帖子的由来. 当然这里要再次感谢小吧主甜甜的拨冗配合与鼎力相助.
内容方面主要参考了:
https://wikiwiki.jp/majsoul-api/%E7%89%8C%E8%AD%9C%E3%82%92%E8%AA%AD%E3%82%80%E3%81%AB%E3%82%83
如果你日语比较好的话, 更推荐看这个网站, 该网站讲解更详细.
另外, 若有雀魂吧吧友读到了此专栏, 欢迎转载此专栏到雀魂吧.
关于烧绳的笑话
对于大部分玩家来说, 雀魂牌谱的作用就是游戏内回放对局, 少数会拿去跑Mortal, Naga之类的AI, 但不管怎样, 常规手段下无法解决甜甜遇到的这个问题:

牌谱链接: (南四局0本场)
https://game.maj-soul.com/1/?paipu=210815-6da08e40-2605-42fb-a5e3-f8aa5940362a_a111703554
这里, 主视角甜甜和对家都听到了3p, 此时下家放铳, 甜甜立马点和, 但对家在思考是否要和而在读条, 过了几秒之后甜甜认为可能是一炮多响而且有人烧绳, 于是发了一个八木唯的第8个表情: (这里对甜甜的原贴勘误: 是第8个表情不是第7个, 7是下标)

又过了几秒, 对家最终选择拒和, 只有甜甜和牌.
看上去没问题对不对?但如果站在上家或下家的视角看这个过程, 没注意到甜甜对家也听3p的话, 很大概率会认为是甜甜在烧绳, 和牌还要嘲讽. 所以这就是第一个帖子的由来:

后来, 甜甜发了另一个帖子(就是本专栏开头提到的那个), 从牌谱信息的角度给自己”洗脱罪名”(后来得知, 这张图的内容是一位推特上叫yohko_arimura的姐姐做的):

这张图详细记录了下家打出3p之后的操作, 包括甜甜发表情时间和对家拒和时间, 可以说是非常全面, 一目了然. 于是, 这里就引出了从计算机的角度来分析”牌谱里到底记载了什么”, 这也是本专栏的重点.
如何得到牌谱信息文件
首先, 常规方法是不行的, 比如直接浏览器F12在Network里面文件一个一个找, 因为雀魂的牌谱信息是封装在protobuf里面的(虽然我也没接触过), 不过还是有特殊方法(下面就是以上面那个”烧绳谱”作为例子):
电脑浏览器登录网页版雀魂, F12打开调试界面, 在Console界面输入以下脚本:
function GetPaipuJSON(paipulink = "") { if (paipulink === "") paipulink = prompt("Please Enter a Paipu Link or Paipu UUID."); if (paipulink === "") return; paipulink = paipulink.split('='); paipulink = paipulink[paipulink.length - 1].split('_'); let uuid = paipulink[0]; if (paipulink.length > 2 && parseInt(paipulink[2]) === 2) uuid = game.Tools.DecodePaipuUUID(uuid); const pbWrapper = net.ProtobufManager.lookupType(".lq.Wrapper"); const pbGameDetailRecords = net.ProtobufManager.lookupType(".lq.GameDetailRecords"); function parseRecords(gameDetailRecords, json) { try { if (gameDetailRecords.version === 0) { for (let i in gameDetailRecords.records) { const record = (pbWrapper.decode(gameDetailRecords.records[i])); const pb = net.ProtobufManager.lookupType(record.name); const data = JSON.parse(JSON.stringify((pb.decode(record.data)))); json.records[i] = {name: record.name, data: data}; } } else if (gameDetailRecords.version === 210715) { for (let i in gameDetailRecords.actions) { if (gameDetailRecords.actions[i].type === 1) { const record = (pbWrapper.decode(gameDetailRecords.actions[i].result)); const pb = net.ProtobufManager.lookupType(record.name); const data = JSON.parse(JSON.stringify((pb.decode(record.data)))); json.actions[i].result = {name: record.name, data: data}; } } } else throw ("Unknown version: " + gameDetailRecords.version); } catch (e) { console.log(e); } return json; } async function fetchData(url) { const response = await fetch(url); const arrayBuffer = await response.arrayBuffer(); return new Uint8Array(arrayBuffer); } function download(data, uuid) { let a = document.createElement("a"); a.href = URL.createObjectURL( new Blob([JSON.stringify(data, null, " ")], {type: "text/plain"})); a.download = "paipu_" + uuid + ".json"; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } app.NetAgent.sendReq2Lobby( "Lobby", "fetchGameRecord", {game_uuid: uuid, client_version_string: GameMgr.Inst.getClientVersion()}, async function (error, gameRecord) { if (gameRecord.data === "") gameRecord.data = await fetchData(gameRecord.data_url); const gameDetailRecordsWrapper = pbWrapper.decode(gameRecord.data); const gameDetailRecords = pbGameDetailRecords.decode(gameDetailRecordsWrapper.data); let gameDetailRecordsJson = JSON.parse(JSON.stringify(gameDetailRecords)); gameDetailRecordsJson = parseRecords(gameDetailRecords, gameDetailRecordsJson); gameRecord.data = ""; let gameRecordJson = JSON.parse(JSON.stringify(gameRecord)); gameRecordJson.data = {name: gameDetailRecordsWrapper.name, data: gameDetailRecordsJson}; download(gameRecordJson, uuid); }); } GetPaipuJSON();

网页应该会弹出一个类似下图一样的提示框, 把想要分析的牌谱链接输进去, 点确定:
https://game.maj-soul.com/1/?paipu=210815-6da08e40-2605-42fb-a5e3-f8aa5940362a_a111703554

就会下载一个json文件, 这个就是牌谱信息文件. 这是一个文本文件, 可以用包括记事本在内的文本编辑器打开, 不过这个文件比较大, 建议用适合开发, 带有语法高亮的编辑器软件打开.

接下来的重心就是分析这个json文件.
牌谱信息文件的格式
如果你之前接触过json格式的文件, 那么下面你会接受的很快: json文件的本质就是”键-值”(key-value)对的集合. 不过这里”键”的种类非常多, 这里只列举一些常见的, 详细内容还得看这个日文网站:
https://wikiwiki.jp/majsoul-api/%E7%89%8C%E8%AD%9C%E3%82%92%E8%AA%AD%E3%82%80%E3%81%AB%E3%82%83
这个json文件主要分为两部分: head和data. head就是存一些摘要性质的和对局核心内容关系不大的内容, 而data就是具体的对局细则.

head部分又分为6个部分, 前三个比较简单, uuid是唯一区分牌谱的字符串, 也是非匿名牌谱链接的一部分, 比如上面那个谱的uuid就是210815-6da08e40-2605-42fb-a5e3-f8aa5940362a, 而start_time和end_time很好理解, 就是对局开始时间和结束时间, 不过这里是Unix时间戳的格式.
config是记录对局游戏类型与规则的(段位还是友人, 四麻还是三麻, 东风战还是半庄战).
accounts是记录对局时玩家信息的, 四麻就有四项, 每一项对应一个玩家, 主要信息有:
account_id(这又和我之前几个专栏串起来了): 唯一表示账号的id
seat: 座次, 0, 1, 2, 3分别代表东南西北起
nickname: 昵称, 没啥好说的, 值得注意的是如果对局时其他服名称被屏蔽成”放浪雀士”的话, 在这里会显示成原本名称
avatar_id: 所用角色及其服饰, 即这方面的对局信息以角色的那个服饰为单位
character: 所用角色更详细的信息, 比如对局时好感度多少, 是否已经契约之类
level和level3就是对局时四麻和三麻的段位分
avatar_frame: 头像框
verified: 1表示主播账号(有猫爪子), 2表示职业玩家(带个P表示Pro), 0就是正常玩家
result记录各玩家的终局点数情况, 按照点数从高到低排列, 记录了以下信息:
seat: 座次, 同上
total_point: 素点, 就是终局点数加上马点减去原点的值
part_point_1: 终局点数
part_point_2 不知道有什么用
grading_score: pt得失
gold: 铜币得失

相比来说data里面东西就比head多多了.
data的name表示记录牌谱详细信息的”功能”名称是”.lq.GameDetailRecords”, 事实上只要是”.lq”开头的名称大多都与牌谱信息有关.
下面还有个子项version, 发生在2021年7月15号之前的谱是0, 之后的谱是210715, 这前后牌谱信息文件的格式有所不同, 但现在目前基本都是后者了, 影响不大
下面就是正文, 正式记录对局详细信息的操作部分(actions):
actions里面的内容
为了描述方便, 这里就上面”烧绳”那个场景作解释

这是和之前那张记录牌谱信息的图的内容对应的部分.
Passed: 已经过去时间: 从匹配成功时算起, 单位毫秒
Type: 这里又分两种:
第一种: 记录操作大类”action.user_input.type”. 1表示发表情, 2表示自家在自摸巡的操作, 3表示他家在自摸巡的操作, 5表示一局结束后点"确定", 6表示因无操作自动模切后点击"我回了", 7表示开始对局, 8和9表示断线和重连
第二种: 记录具体操作
“action.result.data.operations.operation_List.type”: 可选择项
”action.user_input.cpg.type”: 实际选择项
又分成两种:
他家自摸巡选项: 2: 吃, 3: 碰, 5: 杠, 9: 荣和, 15: 照射
自家自摸巡选项: 1: 打牌, 4: 暗杠, 6: 加杠, 7: 立直, 8: 自摸, 10: 九种九牌, 11: 拔北, 12: 换牌, 13: 定缺(0,1,2分别代表筒万索), 14: 暗牌, 16: 维持, 17: 暗牌立直
result: 结果, 也有很多种, 打出牌就是 lq.RecordDiscardTile, 摸牌是lq.RecordDealTile,下一小局就是lq.RecordNewRound, 等等.
seat: 也是同上, 座次
tile: 操作的牌, 结果是Discard系列就是打出的牌, 是Deal系列那就是摸到的牌
is_liqi: 是否是立直宣言牌, 下面的is_wliqi也是同理
zhenting: 这张牌通过了是否会导致玩家振听, 四项分别对应座次的四个玩家
time_add和time_fixed分别表示附加时间和固定时间, 按照段位的5+20的话5是time_fixed, 20是time_add.
例子和剩下的部分可以看翻译的图:



可以看到: 甜甜是在点荣之后5秒发的表情, 而对家是之后1秒拒和的.
如果遇到不理解的键内容, 就可以去查那个网站,
有了牌谱信息文件能干什么
1. 查看玩家信息
这个很容易理解, 不过仅此而已作用就不大了
2. 统计玩家角色, 装扮使用情况, 分析各段位玩家的出现频率与时间
既然该文件包含对局玩家的详细信息, 那自然可以知道该玩家用的什么角色和哪些装扮, 这样就可以做到”雀士使用人数普查”, 正好甜甜也在做这个, 还有”一天当中什么时间玉之间雀圣比较多”(yohko姐姐做过这个), “什么装扮受欢迎”, 但这些的前提是能做到可以批量拿到牌谱, 虽说有些脚本能做到下载本账号的最近1000个牌谱, 但就大量玩家统计而言, 我还是做不到以时间为关键词进行爬取(比如, 抽取某一天金之间及以上段位场的对局), 但我有两个思路可以供有兴趣的读者尝试:
1) 查阅雀魂牌谱屋爬取雀魂金之间及以上对局的代码, 弄懂, 掌握其原理(我太菜了, 几乎不懂js, 到现在都不知道实现的详细具体原理), 又或者直接从牌谱屋里爬取牌谱(这个作用有限, 也是只能以玩家为单位而不是以时间, 这个我在专栏结尾详细说说. 牌谱屋作者估计也不会让你直接调用存放在后台的大量数据, 不过我觉得凡事都可以试一下, 给牌谱屋作者发个邮件询问也是可行的)
2) 通过雀魂的观战接口, 自己通过脚本录制观战过程中玩家的行为分析得到”自己制作的”牌谱, 又或者能发现观战接口与该局的牌谱之间的联系, 很明显这个难度会更大.

3. 自制牌谱回放
详见: //www.bilibili.com/read/cv37140032
或者在Github上阅读: https://github.com/Fat-pig-Cui/majsoul-replay-editor

以上就是获取, 分析, 利用牌谱信息文件的流程, 当然, 我相信这也只是冰山一角, 还有很多需求(爬牌谱)没有解决, 还需要慢慢探索.
下面是我研究如何批量下载牌谱信息文件的一些结论(正确性未知), 供有兴趣者参考
上面在浏览器console界面输入的一大串代码中, 有一个比较关键的调用, 就是这个
app.NetAgent.sendReq2Lobby("Lobby", “fetchGameRecord”, …, async function(…) …)
这个函数有四个参数, 后来发现第二个参数fetchGameRecord是一个api调用, 上面这个函数的意思就是给雀魂后端发送一个请求到前台, 请求对应的内容就是fetchGameRecord调用, 而这个调用的参数就是sendReq2Lobby的第三个参数, 第四个函参数应该是对应的响应函数, 用于处理接收的信息.
然后我发现了下载自己近期所有牌谱的脚本, 它和上面差不多, 不过用到的是另一个调用: fetchGameRecordList, 这个调用和上个相比应该是, 上个调用只能处理一个牌谱, 而这个可以处理一个list的牌谱, 后端收到请求的时候会生成或已存在list, 然后根据list发送响应, 我看那个脚本是在玩家点开牌谱界面的时候, 后端就已经准备好牌谱界面的所有牌谱链接, 存放在uiscript.UI_PaiPu.record_map中.
再然后我发现了雀魂牌谱信息(.lq)相关的api列表网站中:
https://wife.awa.moe/mjsoul/api.html
找到了第三个类似的调用: fetchGameRecordsDetail, 但这个调用我还没在脚本中见到使用过, 倒是见到了请求大会战牌谱的fetchCustomizedContestByContestId, 不过可以肯定的是, 所用的所有调用都跑不出上面这个网站列的范围.
就在我想雀魂牌谱屋用的是上面三个调用中的哪个时, 发现用这三个都搜不到usage, 后来发现可能是作者有意为之, 因为上面三个调用都涉及到了牌谱的uuid, 作者不希望公开uuid, 然后我没看懂github上牌谱屋仓库是怎么爬牌谱的, 就不知道怎么办了(笑死).


可以git clone一下雀魂牌谱屋的github仓库慢慢研究(有两个, 另一个是 scripts):
https://github.com/SAPikachu/amae-koromo/
除了特别感谢@甜甜cbstt外, 还要感谢
雀魂牌谱屋作者: @SAPikachu (没想到作者在b站有号)
yohko_arimura
网站作者:
https://wikiwiki.jp/majsoul-api/%E7%89%8C%E8%AD%9C%E3%82%92%E8%AA%AD%E3%82%80%E3%81%AB%E3%82%83