二七王:I-3/I-2/M-2 服务端补齐阶段·控制权·结算重连下发面

I-3 阶段与控制权改由服务端唯一维护并逐包下发:
- fapai / jiaofen / shangzhuang / xuanzhu / maipai / jiesuan 各补 step(取自 o_paiju.step)
- shangzhuang / xuanzhu 补 nextseat(控制权 = o_paiju.banker,选主/投降/埋牌均只由庄家做)
- deskinfo 的 ChooseMain / BuryCards 各补 seat(控制权,与增量推送 nextseat 同源同值)
- 各下发点的真实值先用探针跑真实一局逐个核实;顺带发现 shangzhuang.data.seat 是
  最后一个叫分者而非控制权(本局实测 2 vs 庄家 0),故控制权另起名 nextseat
- get_paiju_account 统一把 step 置 6:解散结算此前无人推进 step,包里给不出诚实的阶段值

I-2 结算阶段的重连包补发抠底明细与大局结算:
- get_paiju_account 末尾一并冻结 tmp_jiesuan_bottom / tmp_jiesuan_account
- get_deskinfo case 6 的 Balance 据此补 bottom / account,与 jiesuan 推送同源同值、
  门控一致(投降/解散无 bottom、非末局无 account);新局开始时三份快照一并清除
- 修复「结算面板开着时断线重连/硬刷新 → 抠底明细与末局大结算空白」

M-2 seatlist 不再以活引用挂进下发包:
- 新增 get_seatlist() 深拷贝快照,maipai / chupai / deskinfo.PushCards 三处改用它

测试:test_rpc 新增 I-3/I-2/M-2 三节(131→166 checks)、test_desk 新增 fapai
阶段与控制权用例(6→10);全部新增守卫已逐条做「退回修复必转红」反向验证(15/15)。
test_leak 的可见性规则同步收紧到「本局结算(step6)后埋牌底牌才全场公开」,
并修掉一处把阶段写死成 step5 的报错文案;整局泄露审计仍全绿。
协议文档同步补充上述字段说明。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 21:30:56 +08:00
co-authored by Claude Opus 5
parent cfa0409d94
commit 5d981a722e
8 changed files with 313 additions and 14 deletions
+7
View File
@@ -81,7 +81,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
o_desk.prepare = [0,0,0];
if (o_desk.paiju_list.length > 0){
//上一局的结算快照三件套一并清除(新局开始后重连不该再看到上一局的结算面板)
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_aset;
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_bottom;
delete o_desk.paiju_list[o_desk.paiju_list.length - 1].tmp_jiesuan_account;
}
//新开一局
@@ -95,6 +98,10 @@ var cls_youle_erqiwang_desk = cls_youle_erqiwang_desk || {
msg.data.success = true;
msg.data.asetidx = o_desk.paiju_list.length;
msg.data.asetcount = o_desk.o_room.asetcount;
//阶段:发完牌即 step1(叫分)。由服务端权威给出、不让前端按 rpc 推——
//没有包承载的状态变化等于逼前端去猜(server 03 §1.4 / 红线「状态机唯一在服务端」)
msg.data.step = o_desk.method.curr_paiju().step;
//控制权:当前等待叫分者。与重连包 CallRun.seat 同一个 get_callgrade_seat()(SSOT)
msg.data.seat = o_desk.method.curr_paiju().method.get_callgrade_seat();
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
for (var i = 0; i < o_desk.o_room.seatlist.length; i++) {
+23 -1
View File
@@ -95,6 +95,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
break;
case 2: //选主
deskinfo.ChooseMain = {};
//控制权:选主/投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验以 banker 为准)。
//与 shangzhuang 推送的 nextseat 同源同值——两条路径都由服务端算好给出,
//前端不再自己套「选主 = 庄家」这条规则(字段名与 CallRun.seat 一致:分组内的 seat = 轮到谁)
deskinfo.ChooseMain.seat = paiju.banker;
//庄家
deskinfo.ChooseMain.banker = paiju.banker;
//庄家叫分
@@ -120,6 +124,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
break;
case 3: //埋牌
deskinfo.BuryCards = {};
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验以 banker 为准)。
//与 xuanzhu 推送的 nextseat 同源同值
deskinfo.BuryCards.seat = paiju.banker;
//庄家
deskinfo.BuryCards.banker = paiju.banker;
//庄家叫分
@@ -183,7 +190,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0;
//座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2)
if (!cfg.nocheck){
deskinfo.PushCards.seatlist = paiju.seatlist;
//【深拷贝】与 maipai / chupai1/2/3 共用同一处快照函数,且不把活表挂进下发包
//(paiju.seatlist 是 do_playcard 每手就地改写的活对象,见 class.paiju.js get_seatlist)
deskinfo.PushCards.seatlist = cls_youle_erqiwang_paiju.get_seatlist(paiju);
if (seat != paiju.banker){
var _lp = paiju.method.get_liangpai();
if (_lp){
@@ -208,8 +217,21 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
//玩家的准备状态
deskinfo.Balance.readystate = o_room.o_desk.prepare;
if (o_room.o_desk.prepare[seat] == 0){
//结算面板的三件套:与 jiesuan 推送【同源同值】(都取 get_paiju_account 末尾
//冻结的那份快照)。只给 aset 会让「结算面板还开着时断线重连/硬刷新」丢掉
//抠底明细与末局大结算——增量路径有、重连路径空白,正是「同一份数据两条路径
//给的不一样」(server 红线:发全下发面,漏发修在服务端发包处)。
//结算情况
deskinfo.Balance.aset = paiju.tmp_jiesuan_aset;
//抠底明细:仅正常出牌结算存在(投降/解散没有抠底这一步),与 jiesuan.bottom 同一份
if (paiju.tmp_jiesuan_bottom){
deskinfo.Balance.bottom = paiju.tmp_jiesuan_bottom;
}
//大局结算:仅末局或解散时存在,与 jiesuan.account 同一份。
//「有就带、没有就不带」与 jiesuan 推送的取舍完全一致,前端两条路径同一套解析
if (paiju.tmp_jiesuan_account){
deskinfo.Balance.account = paiju.tmp_jiesuan_account;
}
}
break;
}
+45 -2
View File
@@ -180,8 +180,15 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
paiju.playhistory = [];
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
paiju.result = null;
//结算包,供断线重连时使用,新开一局时清除
//结算包快照,供断线重连时使用,新开一局时清除。三件套与 jiesuan 推送【同源同值】:
// tmp_jiesuan_aset 单局结算(三种结算来源恒有)
// tmp_jiesuan_bottom 抠底明细(仅正常出牌结算有;投降/解散为 null)
// tmp_jiesuan_account 大局结算(仅末局或解散有;其余为 null)
//只存 aset 会让「结算面板还开着时重连/硬刷新」丢掉抠底明细与大局结算(增量路径有、
//重连路径没有),即同一份数据两条路径给的不一样。唯一写入处是 get_paiju_account 末尾。
paiju.tmp_jiesuan_aset = null;
paiju.tmp_jiesuan_bottom = null;
paiju.tmp_jiesuan_account = null;
//初始化座位列表
init_seatlist();
@@ -295,6 +302,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
return cls_youle_erqiwang_paiju.get_pushlist(paiju);
}
//获取三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)
paiju.method.get_seatlist = function(){
return cls_youle_erqiwang_paiju.get_seatlist(paiju);
}
//获取某座位本轮跟牌的必出牌(design §5.2)
paiju.method.get_mustcard = function(seat){
return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat);
@@ -510,6 +522,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
return re;
},
//获取【供下发的】三家座位牌况快照(maipai / chupai1/2/3 / 重连包 PushCards.seatlist)。
//结构:外层定长 3(下标=座位),每家 5 个二元数组——前 4 个是花色1~4 的 [无该花色, 无对],
//第 5 个是报副 [剩余主牌数, 剩余主对数]。
//【返回深拷贝】o_paiju.seatlist 是一张全程就地改写的活表(do_playcard 里按缺门/无对/报无主
//逐格赋值),把它本体挂进下发包会让「已发出的包」随后续出牌被回改;平台真实链路虽然立刻
//序列化看不出来,但这是隐患,与 get_playproc / get_pushlist / get_bottomcards 同一条标准。
get_seatlist: function(o_paiju){
var re = [];
for (var s = 0; s < o_paiju.seatlist.length; s++){
var _one = [];
for (var f = 0; f < o_paiju.seatlist[s].length; f++){
_one.push(o_paiju.seatlist[s][f].concat());
}
re.push(_one);
}
return re;
},
get_playproc: function(o_paiju){
var _p = o_paiju.playproc;
if (!_p){
@@ -1028,6 +1058,13 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
//主动推送的 data 必须自带成败标志(dev-guide server 03 §4:成败唯一是 data.success)
msg.data.success = true;
//【阶段唯一写入处】结算包一出,本局即进入 step6:
// 正常出牌结算 do_playcard 已置 6、投降结算 mod.touxiang 已置 6,此处是幂等重申;
// 解散结算此前没有任何地方推进过 step(可能停在 1/2/3/5),由这里落定。
//随后显式下发(见下方 msg.data.step):阶段变更必须有包承载,不让前端按 rpc 猜(server 03 §1.4)
o_paiju.step = 6;
msg.data.step = o_paiju.step;
//读取房间可选规则(缺省:常规算子、不傍王;见 class.config.js 位串约定)
var o_room = o_paiju.o_desk.o_room;
var _cfg = cls_youle_erqiwang_config.parse(o_room.roomtype);
@@ -1167,8 +1204,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
msg = o_paiju.o_desk.method.get_desk_account(msg);
}
//保存结算包,供断线重连时使用
//保存结算包,供断线重连时使用(get_deskinfo 的 case 6 直读这三份,不重算)。
//必须与本次下发的 jiesuan 推送【同源】——取的就是刚组好的同一份 msg.data.*,
//这样「增量收到 jiesuan」与「结算面板开着时重连」得到的结算数据逐字段相等。
//bottom 只有正常出牌结算才有(get_bottom_account 组的)、account 只有末局/解散才有,
//没有的情形显式置 null(不是漏发,是本来就不存在;下发侧据此决定带不带该分组)
o_paiju.tmp_jiesuan_aset = msg.data.aset;
o_paiju.tmp_jiesuan_bottom = msg.data.bottom ? msg.data.bottom : null;
o_paiju.tmp_jiesuan_account = msg.data.account ? msg.data.account : null;
return msg;
}
@@ -158,7 +158,8 @@
| asetidx | 整数 | 当前局数 |
| asetcount | 整数 | 总局数 |
| cards | 数组 | 自己得到的牌id列表 |
| seat | 整数 | 当前等待叫分者的位置 |
| step | 整数 | **本局阶段**(发完牌恒为 `1` 叫分;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
| seat | 整数 | **控制权**:当前等待叫分者的位置。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
| countdown | 整数 | 叫分倒计时 |
---
@@ -188,7 +189,8 @@
| call | 整数 | 分数,0表示不叫 |
| currcall | 整数 | 当前叫到的分数 |
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
| nextseat | 整数 | 下一个叫分者的位置序号 |
| step | 整数 | **本局阶段**(叫分尚未结束,恒为 `1`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
| nextseat | 整数 | **控制权**:下一个叫分者的位置序号,即本包之后「轮到谁」。与重连包 `CallRun.seat` 同源(`get_callgrade_seat()`)|
| countdown | 整数 | 叫分倒计时 |
---
@@ -203,6 +205,8 @@
| call | 整数 | 分数,0表示不叫 |
| banker | 整数 | 庄家的位置序号 |
| grade | 整数 | 庄家的叫分 |
| step | 整数 | **本局阶段**(叫分结束、进入选主/投降,恒为 `2`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,因为选主与投降都只由庄家做(服务端 `mod.xuanzhu`/`mod.touxiang` 的座位校验同样以 `banker` 为准)。<br>⚠️ **别和本包的 `seat` 混用**:`seat` 是**最后一个叫分者**,不是控制权。与重连包 `ChooseMain.seat` 同源同值 |
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
| bottomcards | 数组 | 8 张**底牌**(发牌时没发给玩家、扣在桌面的 8 张)。**庄家恒有**;**闲家仅当 70 分坐庄时才有**(供 3 秒亮牌用),非 70 分时闲家没有该属性(底牌只有庄家可见,design §4)。<br>**顺序在发牌结束时即冻结**(服务端 `paiju.bottomcards`,按「还没有主牌」的口径排一次):底牌是在**选主之前**翻给庄家看的,那时主牌花色尚不存在,故不按主牌花色排。本包与重连包 `ChooseMain.bottomcards` / `BuryCards.bottomcards` 三处**同序**,客户端存一次即可全程复用(含「查底牌」回看)|
| ancard3s | 整数 | **开底**标志。仅 70 分坐庄时出现且为 `1`:表示庄家**摸底**之前,需将 `bottomcards` 这 8 张**底牌**向所有玩家翻开 3 秒(design §4/§7.1);非 70 分无此属性 |
@@ -250,6 +254,8 @@
| --- | --- | --- |
| banker | 整数 | 庄家的位置序号 |
| flower | 整数 | 花色 1方块 2梅花 3红心 4黑桃 |
| step | 整数 | **本局阶段**(选主完成、进入埋牌,恒为 `3`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
| nextseat | 整数 | **控制权**:本包之后「轮到谁」——恒为 `banker`,埋牌只由庄家做(服务端 `mod.maipai` 的座位校验以 `banker` 为准)。与重连包 `BuryCards.seat` 同源同值 |
| countdown | 整数 | 埋牌倒计时 |
| cards | 数组 | 选主后自己手上的牌id列表 |
@@ -280,7 +286,8 @@
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0。<br>**取服务端权威快照 `get_burycard()`,不是客户端请求包里 `cards` 的原序**:按本局主牌花色从大到小排好,与重连包 `PushCards.burycards` **同源同序** |
| seatlist | 数组 | 三家座位牌况,**仅可查牌模式下发**(不查牌无此属性,design §9),结构与门控同 `chupai1/2/3.seatlist` 与重连包 `PushCards.seatlist`。<br>埋牌完成时它是刚初始化的**空表**(每家 `[[0,0],[0,0],[0,0],[0,0],[-1,-1]]`)——下发它是为了让「埋牌完成 → 庄家首出」这段窗口内,增量路径与重连路径拿到同一张表,客户端无需为这段窗口特判 |
| playproc | json | **本轮进行态**(design §5.1),**恒有、三家同值**,结构与门控同 [`chupai1.playproc`](#11-第一个玩家出牌chupai1)/重连包 `PushCards.playproc`(同一个快照函数 `get_playproc()`)。`do_burycard` 内部已 `new_playround` 就地初始化好 round-1 的进行态,此包带的正是这份初值:`round=1`、`start`/`currseat` 均为即将首出的庄家、`cards` 全空、`shuai_demand` 为 `null`。<br>**可见性**:全部字段由桌面公开信息推出(此刻尚无一张牌打出),三家整体下发、不逐座位裁剪 |
| seat | 整数 | 出牌者的位置序号(即将首出的庄家)|
| step | 整数 | **本局阶段**(埋牌完成、进入出牌,恒为 `5`;取值 1叫分 2选主/投降 3埋牌 5出牌 6结算)。阶段由服务端唯一维护、逐包显式给出,客户端**不得按 rpc 名反推**(server 03 §1.4「任何状态变更都要有包承载」/ 前端红线「数据驱动、前端无对局状态机」)。与同一时刻的重连包 `deskinfo.step` 同源同值 |
| seat | 整数 | **控制权**:出牌者的位置序号(即将首出的庄家)。服务端取自权威的 `playproc.currseat`,与重连包 `PushCards.playproc.currseat` 同源同值 |
| countdown | 整数 | 出牌倒计时 |
| liangpai | json | **亮牌**(design §8.2)。**只有闲家、且可查牌模式、且庄家达门槛时才有**;不达标或不查牌则无此属性。结构:`{ cards: [牌id...] }`——**庄家手中全部固定主牌的具体牌面**,按本局主牌序从大到小排好。<br>**固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7**,**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5。<br>**门槛**(任一满足即下发):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6。**不限叫分**。<br>亮出的是**固定的一份**,不随被哪条门槛触发而增减;也**不给数量统计**——数量前端自己数 `cards.length` 即可。<br>口径是庄家**埋牌后的静态快照**(排除已埋的 8 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 |
@@ -451,6 +458,13 @@
- `aset` / `account` 的字段结构与下面 §14 的表完全一致(`aset.multiple = 0`、`upgrade = 0`、各家 `grade = 0`,即解散局不结算子数与算奖;`account` 恒有,见 design §12.2「按当前累计分结算」)。
- 若解散发生在**开战后、首局牌发出前**的窗口内(本游戏首局由 `makewar` 延迟 1 秒创建),`get_disbandRoom` 返回 `null`,平台走「不带 `deskfree`」分支——客户端需容忍 `data.deskfree` 缺失。
**顶层字段(三种结算来源恒有)**
| 参数名 | 类型 | 说明 |
| --- | --- | --- |
| success | 布尔 | 恒 `true`(§0.1)|
| step | 整数 | **本局阶段**,结算包恒为 `6`。三种结算来源(正常/投降/解散)统一由 `get_paiju_account` 给出——解散在此之前 `step` 可能还停在 1/2/3/5,由它落定为 6。客户端**不得按 rpc 名硬编码**,一律读此字段 |
**chupai(出牌包,仅正常出牌结算存在)**
| 参数名 | 类型 | 说明 |
@@ -590,6 +604,7 @@
| 参数名 | 类型 | 说明 |
| --- | --- | --- |
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(选主与投降都只由庄家做)。字段名与 `CallRun.seat` 一致:**各阶段分组里的 `seat` 统一表示「轮到谁」**。与增量推送 `shangzhuang.nextseat` 同源同值;客户端据此设控制权,**不要自己用 `banker` 反推**(那是把「选主=庄家」这条规则搬到前端)|
| banker | 整数 | 庄家 |
| call | 整数 | 叫分 |
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
@@ -604,6 +619,7 @@
| 参数名 | 类型 | 说明 |
| --- | --- | --- |
| seat | 整数 | **控制权**:此刻轮到谁操作——恒为 `banker`(埋牌只由庄家做)。与增量推送 `xuanzhu.nextseat` 同源同值 |
| banker | 整数 | 庄家 |
| call | 整数 | 叫分 |
| multiple | 整数 | 叫分对应的基础子数 `get_base_bycall(call, climb)`,**随房间「爬坡」开关取值**(roomtype 位3):常规算子 65→2 60→3 55→4 50及以下→6;爬坡 65→2 60→3 55→4 50→6 45→7 40→8 35→9 30→10 25→11 20→12 15→13 10→14 5→15;两种模式下 70→2;无叫分→0。与结算包 `aset.multiple` 同源同值(design §7.1/§7.3.1)|
@@ -640,6 +656,8 @@
| --- | --- | --- |
| readystate | 数组 | 所有玩家的准备状态 |
| aset | json | 单局结算包,同上面结算包中的 `aset`,只有当自己的准备状态为0时才有此属性 |
| bottom | json | **抠底包**,结构同 §14 的 `bottom`,与该局 `jiesuan` 推送的 `data.bottom` **同源同值**(服务端在 `get_paiju_account` 末尾冻结的同一份快照)。**仅正常出牌结算才有**(投降/解散没有抠底这一步);同样受「自己的准备状态为 0」门控。<br>**为什么必须有**:结算面板还开着时断线重连/硬刷新,只恢复 `aset` 会让抠底明细整块空白——同一份数据两条路径给的不一样(server 红线「发全下发面」)。<br>**可见性**:埋牌底牌在本局结算时已随 `jiesuan` 广播给三家(design §11 结束亮底),此处不构成额外泄露 |
| account | json | **大局结算包**,结构同 §14 的 `account`,与该局 `jiesuan` 推送的 `data.account` **同源同值**。**仅末局或中途解散才有**,与 `jiesuan` 的取舍完全一致(有就带、没有就不带)|
---
+26 -4
View File
@@ -134,6 +134,10 @@ youle_erqiwang.jiaofen = function(pack){
msg.data.call = call;
msg.data.currcall = o_paiju.method.get_callgrade_value();
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.currcall, _cfg.climb);
//阶段:叫分尚未结束,仍是 step1。阶段唯一由服务端维护、并在包里显式给出——
//前端不得按 rpc 名反推(server 红线「状态机唯一在服务端 / 任何状态变更都要有包承载」)
msg.data.step = o_paiju.step;
//控制权:下一个叫分者(本包的「轮到谁」就是它,与重连包 CallRun.seat 同一个函数)
msg.data.nextseat = o_paiju.method.get_callgrade_seat();
msg.data.countdown = o_desk.method.get_countdown_jiaofen();
o_room.method.sendpack_toother(msg, -1);
@@ -152,6 +156,12 @@ youle_erqiwang.jiaofen = function(pack){
msg.data.banker = o_paiju.banker;
msg.data.grade = o_paiju.call;
msg.data.multiple = cls_youle_erqiwang_arith.get_base_bycall(msg.data.grade, _cfg.climb);
//阶段:叫分结束、进入选主/投降(step2)。与重连包 deskinfo.step 同源同值
msg.data.step = o_paiju.step;
//控制权:选主与投降都只由庄家做(mod.xuanzhu / mod.touxiang 的 SEAT 校验就以 banker 为准),
//故这里的「轮到谁」= o_paiju.banker —— 权威值取自牌局对象本身,不是前端按 banker 自行推导。
//本包的 data.seat 是【最后一个叫分者】、不是控制权,别混用(协议 §4)
msg.data.nextseat = o_paiju.banker;
msg.data.countdown = o_desk.method.get_countdown_xuanzhu();
if (o_paiju.call != 70){
msg.data.touxiang = 0;
@@ -242,6 +252,11 @@ youle_erqiwang.xuanzhu = function(pack){
msg.data.success = true;
msg.data.banker = o_paiju.banker;
msg.data.flower = flower;
//阶段:选主完成即进入埋牌(step3)。与重连包 deskinfo.step 同源同值
msg.data.step = o_paiju.step;
//控制权:埋牌只由庄家做(mod.maipai 的 SEAT 校验同样以 banker 为准),
//权威值取自牌局对象;前端不再自己套「埋牌 = 庄家」这条规则
msg.data.nextseat = o_paiju.banker;
msg.data.countdown = o_desk.method.get_countdown_maipai();
for (var i = 0; i < o_room.seatlist.length; i++) {
msg.conmode = o_room.seatlist[i].conmode;
@@ -320,6 +335,10 @@ youle_erqiwang.maipai = function(pack){
msg.rpc = "maipai";
msg.data = {};
msg.data.success = true;
//阶段:埋牌完成即进入出牌(step5,无独立投降阶段)。与重连包 deskinfo.step 同源同值
msg.data.step = o_paiju.step;
//控制权:本包的 seat 就是「轮到谁」——即将首出的庄家,取自权威的 playproc.currseat
//(do_burycard 里 new_playround 刚写好),与重连包 PushCards.playproc.currseat 同源
msg.data.seat = o_paiju.playproc.currseat;
msg.data.countdown = o_desk.method.get_countdown_chupai();
if (!_cfg.nocheck){
@@ -328,8 +347,9 @@ youle_erqiwang.maipai = function(pack){
//(每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),本身不含任何信息;但「埋牌完成 →
//庄家首出」这段窗口内前端也要有表可画,漏发会让增量路径此刻为空、重连路径却有表。
//可见性:表内只有「某家缺某花色/某花色无对」(由公开的跟牌行为推出)与报无主后
//全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露
msg.data.seatlist = o_paiju.seatlist;
//全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露。
//【深拷贝】seatlist 是全程就地改写的活表,不把活对象挂进下发包(同 playproc/pushlist)
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
}
//本轮进行态(design §5.1):do_burycard 内部已调用 new_playround 就地初始化好了 round-1
//的进行态,与重连包 PushCards.playproc 同一个快照函数、同一结构(三家无差别)。
@@ -491,8 +511,10 @@ youle_erqiwang.chupai = function(pack){
//必须整表下发、不能只带出牌者一家:design §9 要求「一旦有玩家报无主,就为全体三人
//显示另外两家各自的主牌数量与对子数」,服务端已在 do_playcard 里同时刷新三个座位
//(class.paiju.js),只带一家会让另两家的统计滞后到它们各自出牌时才补上。
//字段名与重连包 PushCards.seatlist 一致,前端两条路径复用同一份解析
msg.data.seatlist = o_paiju.seatlist;
//字段名与重连包 PushCards.seatlist 一致,前端两条路径复用同一份解析。
//【深拷贝】seatlist 是全程就地改写的活表(本函数下方 do_playcard 每手都在改它),
//不把活对象挂进下发包(同 playproc/pushlist 的理由)
msg.data.seatlist = cls_youle_erqiwang_paiju.get_seatlist(o_paiju);
}
msg.data.nextseat = o_paiju.playproc.currseat;
msg.data.countdown = o_desk.method.get_countdown_chupai();
+25
View File
@@ -18,4 +18,29 @@ t.eq('轮庄 庄2赢连庄', nextFirstseat(2, 0), 2);
t.eq('轮庄 庄2闲赢下家(回0)', nextFirstseat(2, 1), 0);
t.eq('轮庄 庄0闲赢下家1', nextFirstseat(0, 1), 1);
// ============ I-3 发牌包必须自带阶段 step 与控制权 seat ============
// 红线「状态机唯一在服务端」:阶段与「轮到谁」必须由服务端在包里显式给出。
// 曾经 fapai 只有 seat 没有 step,前端只能按 rpc 名硬编码 step=1——那就是把阶段推导
// 搬到了前端。反向验证:把 class.desk.js 里的 msg.data.step 删掉,本组第一条必转红。
function firstFapai() {
const sent = [];
const o_room = {
seatlist: [{ conmode: 0, fromid: 0 }, { conmode: 0, fromid: 1 }, { conmode: 0, fromid: 2 }],
asetcount: 6, method: { sendpack_toother: () => { } }
};
const prevApp = global.youle_erqiwang.app;
global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) };
const desk = D.new(o_room);
D.do_new_paiju(desk, 1); // 首个叫分者 = 1 号位(刻意不用 0,避免"恒等于 0"的假绿)
global.youle_erqiwang.app = prevApp;
return { desk, pj: desk.paiju_list[desk.paiju_list.length - 1], fapai: sent.filter(m => m.rpc === 'fapai') };
}
const fp = firstFapai();
t.eq('发牌 fapai 带 step,且 = 牌局的真实阶段(1 叫分)', fp.fapai.map(m => m.data.step), [1, 1, 1]);
t.eq('发牌 fapai.step 与 paiju.step 同源(不是写死的常量)', fp.fapai[0].data.step, fp.pj.step);
t.eq('发牌 fapai 控制权 seat = 当前等待叫分者(firstseat=1)',
fp.fapai.map(m => m.data.seat), [1, 1, 1]);
t.eq('发牌 fapai.seat 与 get_callgrade_seat() 同源',
fp.fapai[0].data.seat, fp.pj.method.get_callgrade_seat());
process.exit(t.done('desk') ? 0 : 1);
+8 -2
View File
@@ -55,7 +55,11 @@ function mayKnow(pj, seat, cid, rpc) {
if (c.dealowner === seat + 1) return true; // 自己的牌
if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒
if (rpc === 'jiesuan' && c.playround === 0) return true; // §11 结束亮埋牌底牌
// §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就
// 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom
// 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」
// 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0
if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true;
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
@@ -124,7 +128,9 @@ function run(roomtype, call) {
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
const out = new Set(); collectCards(di, null, out); pkts++;
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step5) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
// 阶段取【实际的】pj.step:最后一手打完后 step 已转 6,这里拿到的是结算快照,
// 写死 "step5" 会让报错信息指错阶段(曾据此误判过)
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
}
if (pj.method.have_baofu()) {
sent.length = 0;
+156
View File
@@ -614,4 +614,160 @@ mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
global.min_ontimeout = realTO;
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
// ===================== I-3 阶段 step 与控制权 必须由服务端在包里显式给出 =====================
// server 红线:「阶段、控制权(轮到谁)… 必须由服务端唯一维护并在相关下发包里显式给出;
// 任何状态变更都要有包承载」。曾经的缺陷:增量推送里【一个 step 都没有】,选主/埋牌阶段
// 也没有「轮到谁」,前端只能按 rpc 名硬编码 step、并自己套「选主/埋牌 = 庄家」这条规则——
// 那就是前端持有了一小段对局状态机(服务端将来插一个中间阶段,前端不会红、只会静默画错)。
//
// 本组逐包钉死两件事:①每个包的 step == 同时刻 paiju.step(不是常量,是权威值);
// ②每个包的「轮到谁」== 服务端各 RPC 校验所用的同一个权威来源。
// 反向验证:删掉 mod.js/class.desk.js 里任意一处 msg.data.step / nextseat,本组必转红。
const sp = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
const grab = rpc => c.sent.filter(m => m.rpc === rpc);
const out = {};
mod.jiaofen(pack(0, { call: 65 }));
out.jiaofen1 = grab('jiaofen')[0]; out.after_jf1 = { step: pj.step, next: pj.method.get_callgrade_seat() };
mod.jiaofen(pack(1, { call: 0 }));
out.jiaofen2 = grab('jiaofen')[1]; out.after_jf2 = { step: pj.step, next: pj.method.get_callgrade_seat() };
out.deskinfo1 = E.get_deskinfo(c.o_room, 1);
mod.jiaofen(pack(2, { call: 0 })); // → 0 号位上庄
out.shangzhuang = grab('shangzhuang')[0]; out.after_sz = { step: pj.step, banker: pj.banker };
out.deskinfo2 = E.get_deskinfo(c.o_room, 1); // 闲家视角(控制权与视角无关)
mod.xuanzhu(pack(0, { flower: 1 }));
out.xuanzhu = grab('xuanzhu')[0]; out.after_xz = { step: pj.step, banker: pj.banker };
out.deskinfo3 = E.get_deskinfo(c.o_room, 2);
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
out.maipai = grab('maipai')[0]; out.after_mp = { step: pj.step, curr: pj.playproc.currseat };
out.deskinfo5 = E.get_deskinfo(c.o_room, 1);
return { pj, c, out };
})();
const O = sp.out;
// ① 阶段 step:每个包都有,且等于同时刻牌局的真实阶段
t.eq('阶段 jiaofen 带 step,且 == 当时的 paiju.step(仍在叫分=1)', [O.jiaofen1.data.step, O.after_jf1.step], [1, 1]);
t.eq('阶段 jiaofen(第二包) step 仍为 1', O.jiaofen2.data.step, O.after_jf2.step);
t.eq('阶段 shangzhuang 带 step,且 == paiju.step(选主/投降=2)', [O.shangzhuang.data.step, O.after_sz.step], [2, 2]);
t.eq('阶段 xuanzhu 带 step,且 == paiju.step(埋牌=3)', [O.xuanzhu.data.step, O.after_xz.step], [3, 3]);
t.eq('阶段 maipai 带 step,且 == paiju.step(出牌=5)', [O.maipai.data.step, O.after_mp.step], [5, 5]);
// 与重连包 deskinfo.step 同值:增量与全量两条路径必须给同一个阶段
t.eq('阶段 jiaofen.step == 同时刻 deskinfo.step', O.jiaofen2.data.step, O.deskinfo1.step);
t.eq('阶段 shangzhuang.step == 同时刻 deskinfo.step', O.shangzhuang.data.step, O.deskinfo2.step);
t.eq('阶段 xuanzhu.step == 同时刻 deskinfo.step', O.xuanzhu.data.step, O.deskinfo3.step);
t.eq('阶段 maipai.step == 同时刻 deskinfo.step', O.maipai.data.step, O.deskinfo5.step);
// ② 控制权:每个包的「轮到谁」== 服务端自己校验时用的权威来源
t.eq('控制权 jiaofen.nextseat == get_callgrade_seat()(下一个叫分者)', O.jiaofen1.data.nextseat, O.after_jf1.next);
t.eq('控制权 jiaofen 不叫后跳过该家', O.jiaofen2.data.nextseat, O.after_jf2.next);
t.eq('控制权 shangzhuang.nextseat == banker(选主/投降只由庄家做)', O.shangzhuang.data.nextseat, O.after_sz.banker);
t.eq('控制权 xuanzhu.nextseat == banker(埋牌只由庄家做)', O.xuanzhu.data.nextseat, O.after_xz.banker);
t.eq('控制权 maipai.seat == playproc.currseat(即将首出者)', O.maipai.data.seat, O.after_mp.curr);
// 反面:shangzhuang 的 data.seat 是【最后一个叫分者】,不是控制权——两者必须能区分开,
// 否则"直接读 seat 当控制权"的写法也能蒙混过关(本局:最后叫分者=2、庄家=0)
t.eq('控制权 shangzhuang.seat(最后叫分者) 与 nextseat(庄家) 确实不同 → 用例非空转',
O.shangzhuang.data.seat !== O.shangzhuang.data.nextseat, true);
// 重连包:ChooseMain / BuryCards 也必须显式给控制权,且与增量推送同值
t.eq('控制权 重连 ChooseMain.seat == shangzhuang.nextseat', O.deskinfo2.ChooseMain.seat, O.shangzhuang.data.nextseat);
t.eq('控制权 重连 BuryCards.seat == xuanzhu.nextseat', O.deskinfo3.BuryCards.seat, O.xuanzhu.data.nextseat);
t.eq('控制权 重连 PushCards 由 playproc.currseat 给出 == maipai.seat',
O.deskinfo5.PushCards.playproc.currseat, O.maipai.data.seat);
// ===================== I-2 结算阶段(step6)的重连包必须与 jiesuan 推送给同一份结算数据 =====================
// 曾经的缺陷:deskinfo 的 Balance 只给 readystate + aset,而 jiesuan 推送给的是
// chupai + bottom + aset(+ 末局 account)——结算面板还开着时断线重连/硬刷新,
// 抠底明细与末局大结算全部空白(同一份数据两条路径给的不一样)。
function driveToSettle(roomtype, hookDesk) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
if (hookDesk) { hookDesk(c.o_desk, pj); }
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
return { pj, c, jiesuan: c.sent.filter(m => m.rpc === 'jiesuan')[0] };
}
const sv = driveToSettle("00000");
t.eq('结算 用例真的打到 step6', sv.pj.step, 6);
t.eq('结算 jiesuan 带 step=6(阶段同样由包承载)', sv.jiesuan.data.step, 6);
const bal = [0, 1, 2].map(s => E.get_deskinfo(sv.c.o_room, s).Balance);
t.eq('结算 重连 Balance.aset == jiesuan.aset(三家)',
bal.map(b => JS(b.aset)), [0, 1, 2].map(() => JS(sv.jiesuan.data.aset)));
t.eq('结算 重连 Balance.bottom == jiesuan.bottom(抠底明细,三家)',
bal.map(b => JS(b.bottom)), [0, 1, 2].map(() => JS(sv.jiesuan.data.bottom)));
// 用例非空转:抠底分组必须真的有内容(cards 恒 8 张),否则上面两边"同为 undefined"也会绿
t.eq('结算 抠底分组真的有内容(8张埋牌底牌) → 用例非空转', bal[0].bottom.cards.length, 8);
t.eq('结算 重连 deskinfo.step == 6', E.get_deskinfo(sv.c.o_room, 0).step, 6);
// 已按下准备的座位不再重发结算数据(原有门控不变)
sv.c.o_desk.prepare[1] = 1;
const balReady = E.get_deskinfo(sv.c.o_room, 1).Balance;
t.eq('结算 已准备的座位不带 aset/bottom/account(门控不变)',
[balReady.aset, balReady.bottom, balReady.account], [undefined, undefined, undefined]);
// 末局:account 也必须能从重连包拿到。用桩 get_desk_account 造出 account(真实实现要写战绩,
// 与本用例无关),验证的是「get_paiju_account 冻结了它 + get_deskinfo 原样给出」这条链路
const svLast = driveToSettle("00000", (o_desk) => {
o_desk.method.get_desk_account = m => { m.data.account = [{ score: 1 }, { score: 2 }, { score: 3 }]; return m; };
});
svLast.pj.idx = 99; // 末局判定:idx >= asetcount(6)
// idx 改在结算之前才有效,故这里重新走一遍结算路径:直接再调一次 get_paiju_account
const lastMsg = P.get_paiju_account(svLast.pj, 0, { data: {} });
t.eq('结算 末局 jiesuan 带 account', Array.isArray(lastMsg.data.account), true);
t.eq('结算 末局 重连 Balance.account == jiesuan.account',
JS(E.get_deskinfo(svLast.c.o_room, 0).Balance.account), JS(lastMsg.data.account));
// 反面:非末局不带 account(与 jiesuan 推送的取舍一致,不凭空造分组)
t.eq('结算 非末局 jiesuan 无 account', sv.jiesuan.data.account, undefined);
t.eq('结算 非末局 重连 Balance 无 account', bal[0].account, undefined);
// ===================== M-2 seatlist 必须以深拷贝下发(不把活对象挂进下发包)=====================
// paiju.seatlist 是一张全程就地改写的活表(do_playcard 每手都在改缺门/无对/报副)。
// 同一份数组曾同时挂在 maipai 的三个逐座位包、chupai1/2/3 与重连包 PushCards 上——
// 平台真实链路立刻序列化看不出来,但这是隐患,与 playproc / pushlist / bottomcards 同一条标准。
const EMPTY_TABLE = JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]));
const sl = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出问题
const inner = mod.app.SendPack;
let mpLive = null, cpLive = null;
mod.app.SendPack = m => {
if (m.rpc === 'maipai' && mpLive === null) { mpLive = m.data.seatlist; }
if (/^chupai[123]$/.test(m.rpc) && cpLive === null) { cpLive = m.data.seatlist; }
inner(m);
};
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
// 重连快照也取一份活引用(此刻还是初始空表)
const diLive = E.get_deskinfo(c.o_room, 1).PushCards.seatlist;
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
mod.app.SendPack = inner;
return { pj, c, mpLive, cpLive, diLive };
})();
// 用例非空转:整局打完后这张活表必须真的变过,否则"没被回改"是碰巧
t.eq('座位牌况 整局打完后 paiju.seatlist 确实变过 → 用例非空转', JS(sl.pj.seatlist) !== EMPTY_TABLE, true);
t.eq('座位牌况 maipai 下发的是深拷贝(已发出的包不被后续出牌回改)', JS(sl.mpLive), EMPTY_TABLE);
t.eq('座位牌况 chupai1 下发的是深拷贝(打完整局后仍是发出当时的样子)',
JS(sl.cpLive) !== JS(sl.pj.seatlist), true);
t.eq('座位牌况 重连 PushCards 下发的是深拷贝', JS(sl.diLive), EMPTY_TABLE);
// 正面:深拷贝的内容必须与当时的活表逐格相等(拷贝不能拷错/拷浅)
const slNow = P.get_seatlist(sl.pj);
t.eq('座位牌况 get_seatlist 内容与活表逐格相等', JS(slNow), JS(sl.pj.seatlist));
slNow[0][0][0] = 9;
t.eq('座位牌况 get_seatlist 是深拷贝(改副本不影响本体)', sl.pj.seatlist[0][0][0] !== 9, true);
process.exit(t.done('rpc') ? 0 : 1);