diff --git a/server/games/erqiwang/class.desk.js b/server/games/erqiwang/class.desk.js
index 4173bfa..b8f4c42 100644
--- a/server/games/erqiwang/class.desk.js
+++ b/server/games/erqiwang/class.desk.js
@@ -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++) {
diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js
index a6b5efb..c5c98c2 100644
--- a/server/games/erqiwang/class.export.js
+++ b/server/games/erqiwang/class.export.js
@@ -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;
}
diff --git a/server/games/erqiwang/class.paiju.js b/server/games/erqiwang/class.paiju.js
index 95e43bb..b575199 100644
--- a/server/games/erqiwang/class.paiju.js
+++ b/server/games/erqiwang/class.paiju.js
@@ -180,8 +180,15 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
paiju.playhistory = [];
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
paiju.result = null;
- //结算包,供断线重连时使用,新开一局时清除
- paiju.tmp_jiesuan_aset = 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);
}
- //保存结算包,供断线重连时使用
- o_paiju.tmp_jiesuan_aset = msg.data.aset;
+ //保存结算包,供断线重连时使用(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;
}
diff --git a/server/games/erqiwang/docs/protocol/packet_protocol.md b/server/games/erqiwang/docs/protocol/packet_protocol.md
index d754c3b..07a05ee 100644
--- a/server/games/erqiwang/docs/protocol/packet_protocol.md
+++ b/server/games/erqiwang/docs/protocol/packet_protocol.md
@@ -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` 为准)。
⚠️ **别和本包的 `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)。
**顺序在发牌结束时即冻结**(服务端 `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。
**取服务端权威快照 `get_burycard()`,不是客户端请求包里 `cards` 的原序**:按本局主牌花色从大到小排好,与重连包 `PushCards.burycards` **同源同序** |
| seatlist | 数组 | 三家座位牌况,**仅可查牌模式下发**(不查牌无此属性,design §9),结构与门控同 `chupai1/2/3.seatlist` 与重连包 `PushCards.seatlist`。
埋牌完成时它是刚初始化的**空表**(每家 `[[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`。
**可见性**:全部字段由桌面公开信息推出(此刻尚无一张牌打出),三家整体下发、不逐座位裁剪 |
-| 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...] }`——**庄家手中全部固定主牌的具体牌面**,按本局主牌序从大到小排好。
**固定主牌 = 双王 + 全部花色的 2 + 全部花色的 7**,**不含**主花色的普通牌 A/K/Q/J/10/9/8/6/5。
**门槛**(任一满足即下发):固定主牌总数 ≥10 / 王 ≥3 / 7 ≥6 / 2 ≥6。**不限叫分**。
亮出的是**固定的一份**,不随被哪条门槛触发而增减;也**不给数量统计**——数量前端自己数 `cards.length` 即可。
口径是庄家**埋牌后的静态快照**(排除已埋的 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」门控。
**为什么必须有**:结算面板还开着时断线重连/硬刷新,只恢复 `aset` 会让抠底明细整块空白——同一份数据两条路径给的不一样(server 红线「发全下发面」)。
**可见性**:埋牌底牌在本局结算时已随 `jiesuan` 广播给三家(design §11 结束亮底),此处不构成额外泄露 |
+| account | json | **大局结算包**,结构同 §14 的 `account`,与该局 `jiesuan` 推送的 `data.account` **同源同值**。**仅末局或中途解散才有**,与 `jiesuan` 的取舍完全一致(有就带、没有就不带)|
---
diff --git a/server/games/erqiwang/mod.js b/server/games/erqiwang/mod.js
index ff2d7f9..d469199 100644
--- a/server/games/erqiwang/mod.js
+++ b/server/games/erqiwang/mod.js
@@ -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();
diff --git a/server/games/erqiwang/test/test_desk.js b/server/games/erqiwang/test/test_desk.js
index 2583365..fa8fa72 100644
--- a/server/games/erqiwang/test/test_desk.js
+++ b/server/games/erqiwang/test/test_desk.js
@@ -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);
diff --git a/server/games/erqiwang/test/test_leak.js b/server/games/erqiwang/test/test_leak.js
index d35be68..8738dfa 100644
--- a/server/games/erqiwang/test/test_leak.js
+++ b/server/games/erqiwang/test/test_leak.js
@@ -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;
diff --git a/server/games/erqiwang/test/test_rpc.js b/server/games/erqiwang/test/test_rpc.js
index 9e90c0d..0287038 100644
--- a/server/games/erqiwang/test/test_rpc.js
+++ b/server/games/erqiwang/test/test_rpc.js
@@ -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);