二七王:服务端下发面四处缺陷修复(增量路径与重连路径对齐)

前端一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的四处
服务端缺陷,均属「同一份数据两条路径给的不一样 / 有一条根本没给」:

① chupai1/2/3 补发 playproc(本轮进行态)
   与 deskinfo.PushCards 共用新增的 get_playproc() 快照函数,结构取值完全一致。
   playproc 是全程复用的活对象(new_playround 就地重置),故快照做逐字段深拷贝,
   避免已发出的包被后续出牌回改。可见性:全部字段由桌面公开信息推出,
   与既有 PushCards.playproc 一样三家无差别下发。

② deskinfo ChooseMain(step2)/BuryCards(step3) 补发 curmultiple
   与 shangzhuang 推送同源同值;此刻一张牌未出、捡分恒 0,必为 +3(大光)。
   原先重连重建为 0,顶部「抓分」角标掉档。

③ maipai 补发 seatlist
   与 chupai/PushCards 同一张表、同一道查牌模式门控;埋牌完成时为初始化空表。

④ bottomcards / burycards 收敛到单一权威源(SSOT)
   - burycards:maipai 原样回显了客户端请求包里 cards 的顺序,改取 get_burycard()
     权威快照,与 PushCards.burycards 同一函数。请求包只承载「意图」,其顺序不可信。
   - bottomcards:get_bottomcards 按「调用时的 flower」排序,导致上庄推送(flower=-1)
     与重连 BuryCards(flower 已定) 两种顺序。底牌是选主【之前】就翻给玩家看的、
     发牌结束即固定的快照,故改为发牌时算一次并冻结,get_bottomcards 返回其副本
     (order_cards 是原地排序,交出本体会被调用方就地重排)。三个调用点无需改动。

协议文档同步 7 处(新增 playproc/seatlist/curmultiple 字段说明与两处排序口径)。
新增 test_rpc.js「下发面一致性」用例组 21 checks,含 28 轮真实牌局逐包逐座位比对
(249 次、0 不一致)与深拷贝守卫;每条均已用「退回修复必转红」反向验证。
既有 649 checks 未改一条断言,全绿(17 脚本 / 670 checks)。未涉及 shared/。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:17:09 +08:00
co-authored by Claude Opus 5
parent c4c061b0e1
commit 84525359fc
5 changed files with 245 additions and 16 deletions
+10 -2
View File
@@ -6,6 +6,7 @@ if (typeof require !== "undefined"){
var cls_youle_erqiwang_config = require("./class.config.js"); var cls_youle_erqiwang_config = require("./class.config.js");
var cls_youle_erqiwang_arith = require("./class.arith.js"); var cls_youle_erqiwang_arith = require("./class.arith.js");
var cls_youle_erqiwang_desk = require("./class.desk.js"); var cls_youle_erqiwang_desk = require("./class.desk.js");
var cls_youle_erqiwang_paiju = require("./class.paiju.js");
} }
var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
@@ -112,6 +113,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
} else { } else {
deskinfo.ChooseMain.touxiang = 1; deskinfo.ChooseMain.touxiang = 1;
} }
//当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
//选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
//漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
break; break;
case 3: //埋牌 case 3: //埋牌
deskinfo.BuryCards = {}; deskinfo.BuryCards = {};
@@ -129,6 +134,8 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
} }
//主牌花色 //主牌花色
deskinfo.BuryCards.flower = paiju.flower; deskinfo.BuryCards.flower = paiju.flower;
//当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择) //埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
break; break;
case 5: //出牌 case 5: //出牌
@@ -157,8 +164,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
deskinfo.PushCards.grade = jian_grade.grade; deskinfo.PushCards.grade = jian_grade.grade;
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确 //当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple(); deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
//当前的出牌情况 //当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
deskinfo.PushCards.playproc = paiju.playproc; //不把活对象挂进下发包,见 class.paiju.js get_playproc)
deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有, //本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发) //算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
if (paiju.playproc && paiju.playproc.currseat == seat){ if (paiju.playproc && paiju.playproc.currseat == seat){
+67 -6
View File
@@ -116,6 +116,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
tmpdeal.length = tmpdeal.length - 1; tmpdeal.length = tmpdeal.length - 1;
} }
//冻结底牌快照(发牌留桌的 8 张)
//——内容在发牌结束时就已确定(dealowner==0 之后不再变),【显示顺序也必须一并冻结】。
//理由(SSOT):排序用的 order_cards 依赖主牌花色,而底牌是在【选主之前】的上庄时刻
//就翻给庄家看的——那时 paiju.flower 还是 -1,根本还没有主牌。若每次调用都按「当时的
//flower」重排,上庄推送(flower=-1)与重连 BuryCards(step3,flower 已定)会对同一份数据
//给出两种顺序(实测:主花色的那张 K 被排到队首),前端增量路径与重连路径因此对不上。
//故在发牌结束时按【翻底那一刻的口径】算一次并冻结,之后所有下发点只取这份快照的副本。
var init_bottomcards = function(){
var ary = [];
for (var i = 0; i < paiju.cards.length; i++){
if (paiju.cards[i].dealowner == 0){
ary.push(paiju.cards[i].id);
}
}
//此处 paiju.flower 必为 -1(选主在 step2 才发生),即「无主牌」序 = 翻底时的顺序
paiju.bottomcards = cls_youle_erqiwang_arith.order_cards(paiju.flower, ary);
}
//进入叫分阶段 //进入叫分阶段
var start_call = function(){ var start_call = function(){
paiju.step = 1; paiju.step = 1;
@@ -149,6 +167,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
paiju.call = -1; paiju.call = -1;
//主牌花色 //主牌花色
paiju.flower = -1; paiju.flower = -1;
//底牌快照(发牌留桌的 8 张,含显示顺序):发牌结束时冻结,见 init_bottomcards
paiju.bottomcards = [];
//当前出牌情况 //当前出牌情况
paiju.playproc = {}; paiju.playproc = {};
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散 //输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
@@ -160,6 +180,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
init_seatlist(); init_seatlist();
//初始化牌列表并发牌 //初始化牌列表并发牌
init_cards(); init_cards();
//冻结底牌快照(必须在发牌之后、选主之前)
init_bottomcards();
//进入叫分阶段 //进入叫分阶段
start_call(); start_call();
@@ -445,15 +467,54 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
} }
}, },
//获取底牌 //获取底牌(发牌留桌的 8 张):唯一来源是发牌时冻结的快照 o_paiju.bottomcards(见 init_bottomcards)。
//【返回副本】——order_cards 是原地排序,把快照本体交出去会被调用方就地重排,冻结即失效。
get_bottomcards: function(o_paiju){ get_bottomcards: function(o_paiju){
var aryCardIDs = []; return o_paiju.bottomcards.concat();
for (var i = 0; i < o_paiju.cards.length; i++){ },
if (o_paiju.cards[i].dealowner == 0){
aryCardIDs.push(o_paiju.cards[i].id); //获取【供下发的】当前出牌情况快照:deskinfo.PushCards 与 chupai1/2/3 共用这一处,
//两条路径因此结构完全一致(前端增量回放与重连重建可复用同一份解析)。
//必须【深拷贝】:playproc 是一个全程复用的活对象(new_playround 就地重置同一个对象,
//do_playcard 也就地改 cards/maxseat/maxcard),直接把它挂进 msg 会让「已发出的包」
//随后续出牌被回改;平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。
//structure:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard/
// cards(定长3、下标=座位)/shuai_demand
get_playproc: function(o_paiju){
var _p = o_paiju.playproc;
if (!_p){
return null;
}
var re = {};
re.round = _p.round;
re.start = _p.start;
re.currseat = _p.currseat;
re.startcount = _p.startcount;
re.startflower = _p.startflower;
re.starttype = _p.starttype;
re.maxseat = _p.maxseat;
re.maxcard = _p.maxcard;
//本轮各位置出的牌:定长 3、下标 = 座位序号;尚未出牌的位置留空(序列化后为 null)
re.cards = [];
re.cards.length = 3;
if (_p.cards){
for (var i = 0; i < 3; i++){
if (_p.cards[i]){
re.cards[i] = _p.cards[i].concat();
}
} }
} }
return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, aryCardIDs); //首家甩牌的分量需求(§5.4.4);tractors 是数组,同样要拷贝,非甩牌为 null
if (_p.shuai_demand){
var _sd = {};
_sd.tractors = _p.shuai_demand.tractors ? _p.shuai_demand.tractors.concat() : [];
_sd.pairs = _p.shuai_demand.pairs;
_sd.singles = _p.shuai_demand.singles;
re.shuai_demand = _sd;
} else {
re.shuai_demand = null;
}
return re;
}, },
//选主 //选主
@@ -183,7 +183,7 @@
| banker | 整数 | 庄家的位置序号 | | banker | 整数 | 庄家的位置序号 |
| grade | 整数 | 庄家的叫分 | | grade | 整数 | 庄家的叫分 |
| 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)| | 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)| | 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 分无此属性 | | ancard3s | 整数 | **开底**标志。仅 70 分坐庄时出现且为 `1`:表示庄家**摸底**之前,需将 `bottomcards` 这 8 张**底牌**向所有玩家翻开 3 秒(design §4/§7.1);非 70 分无此属性 |
| cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 | | cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 |
| countdown | 整数 | 选主倒计时 | | countdown | 整数 | 选主倒计时 |
@@ -256,7 +256,8 @@
| 参数名 | 类型 | 说明 | | 参数名 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| cards | 数组 | 埋牌后手上的牌,去掉了埋牌,庄家才有此属性,闲家没有该属性 | | cards | 数组 | 埋牌后手上的牌,去掉了埋牌,庄家才有此属性,闲家没有该属性 |
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0 | | 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]]`)——下发它是为了让「埋牌完成 → 庄家首出」这段窗口内,增量路径与重连路径拿到同一张表,客户端无需为这段窗口特判 |
| seat | 整数 | 出牌者的位置序号(即将首出的庄家)| | seat | 整数 | 出牌者的位置序号(即将首出的庄家)|
| countdown | 整数 | 出牌倒计时 | | 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 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 | | 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 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 |
@@ -297,6 +298,7 @@
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)| | baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
| cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 | | cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与结算包 `aset.upgrade` 同口径(`3`/`2`/`1` 庄家大光/小光/过庄,`-N` 闲家升 N 级,`0` 叫分未定)。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送。符号是「谁赢」的区分,客户端判定动画靠它分辨 | | curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与结算包 `aset.upgrade` 同口径(`3`/`2`/`1` 庄家大光/小光/过庄,`-N` 闲家升 N 级,`0` 叫分未定)。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送。符号是「谁赢」的区分,客户端判定动画靠它分辨 |
| playproc | json | <a id="playproc-def"></a>**本轮进行态**(design §5.1)。**`chupai1/2/3` 三个包恒有,三家同值**;结构与重连包 [`PushCards.playproc`](#断线重连deskinfo) **完全一致**(服务端同一个快照函数 `get_playproc()`,前端增量回放与重连重建复用同一份解析)。<br>字段:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前该谁出、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮暂时最大者、`maxcard` 其牌编码、`cards` 本轮三家各自出的牌(**定长 3,下标 = 座位序号**,未出的位置为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 `null`)。<br>⚠️ **`chupai3` 带的是【下一轮】的进行态**(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`):本轮第三家一出完,服务端就地开了新一轮。这与「此刻断线重连拿到的 `PushCards.playproc`」完全相同——本轮那三手牌客户端已由 `chupai1/2/3` 各自的 `seat`+`cards` 收到,收牌动画后即清台。**唯一例外**:`chupai3` 打完最后一张牌时本包会转成 `jiesuan`(见 §14),那种情况下**不带** `playproc`。<br>**可见性**:全部字段都由桌面公开信息推出(`cards` 就是已摊在桌上的牌,`shuai_demand` 与 `chupai1.shuai` 等价且甩出的牌本身已公开),故三家整体下发、不逐座位裁剪 |
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 | | mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 |
--- ---
@@ -314,6 +316,7 @@
| countdown | 整数 | 出牌倒计时 | | countdown | 整数 | 出牌倒计时 |
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)| | baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
| cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 | | cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 |
| playproc | json | **本轮进行态**,恒有、三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。此时 `cards` 已含首家与本家两手牌,`currseat` 指向第三家 |
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 | | mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 |
--- ---
@@ -334,6 +337,7 @@
| maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 | | maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 |
| grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 | | grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,口径同 `aset.upgrade`。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 | | curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,口径同 `aset.upgrade`。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送 |
| playproc | json | **本轮进行态**,三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。⚠️ 本包带的是**下一轮**的进行态(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`),与此刻重连拿到的 `PushCards.playproc` 一致。**牌局在本包打完(转为 `jiesuan`)时不带此属性** |
--- ---
@@ -568,8 +572,9 @@
| call | 整数 | 叫分 | | 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)| | 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)|
| countdown | 整数 | 选主倒计时 | | countdown | 整数 | 选主倒计时 |
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)| | bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`BuryCards.bottomcards` **完全同序**(见 §4)|
| touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同一决策点,见 touxiang 包与 design §4 | | touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同一决策点,见 touxiang 包与 design §4 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。选主阶段一张牌都还没出、捡分恒为 0,故**必为 `3`**(大光)。三家同值。有此字段,重连后顶部「抓分」角标才不会掉回 0 |
> 选主阶段前端需在每个花色按钮上显示"该花色在庄家手中的对子数"(design §4/§11)——庄家的完整手牌由 `MyCards` 提供(庄家为 36 张),对子数由前端据此计算,服务端不额外下发。 > 选主阶段前端需在每个花色按钮上显示"该花色在庄家手中的对子数"(design §4/§11)——庄家的完整手牌由 `MyCards` 提供(庄家为 36 张),对子数由前端据此计算,服务端不额外下发。
@@ -582,7 +587,8 @@
| 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)| | 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)|
| flower | 整数 | 主牌花色 | | flower | 整数 | 主牌花色 |
| countdown | 整数 | 埋牌倒计时 | | countdown | 整数 | 埋牌倒计时 |
| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)| | bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`ChooseMain.bottomcards` **完全同序**——**不**按本局主牌花色重排(见 §4)|
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。埋牌阶段同样一张牌未出、捡分恒为 0,故**必为 `3`**(大光)。三家同值 |
> 埋牌阶段无投降(投降是 step2 与选主互斥的选择,选主后即不可再投降)。 > 埋牌阶段无投降(投降是 step2 与选主互斥的选择,选主后即不可再投降)。
@@ -595,11 +601,11 @@
| 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)| | 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)|
| flower | 整数 | 主牌花色 | | flower | 整数 | 主牌花色 |
| countdown | 整数 | 出牌倒计时 | | countdown | 整数 | 出牌倒计时 |
| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0 | | burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0。与 `maipai.burycards` **同源同序**(同一个 `get_burycard()`,按本局主牌花色从大到小)|
| grade | 整数 | 当前的捡分分数 | | grade | 整数 | 当前的捡分分数 |
| gradecards | 数组 | 当前的捡分分牌,只有闲家有此属性 | | gradecards | 数组 | 当前的捡分分牌,只有闲家有此属性 |
| playproc | json | 当前出牌情况 `o_paiju.playproc`:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前出牌位置、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮最大者位置、`maxcard` 最大牌编码、`cards` 本轮三家各自出的牌(数组下标=位置序号)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 null,供跟牌逐分量强制匹配,design §5.4.4)。**两种查牌模式下恒有此属性**——`cards` 是当前这一轮桌面上的牌,不属于「查牌」,屏蔽了后出的人就无从跟牌(design §9 末尾)| | playproc | json | **本轮进行态**,与 `chupai1/2/3.playproc` **同源同结构**(服务端同一个 `get_playproc()` 快照函数,见 [§11 `playproc`](#11-第一个玩家出牌chupai1)):`round` 第几轮、`start` 本轮首出位置、`currseat` 当前出牌位置、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮最大者位置、`maxcard` 最大牌编码、`cards` 本轮三家各自出的牌(**定长 3**,下标=位置序号,未出为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 null,供跟牌逐分量强制匹配,design §5.4.4)。**两种查牌模式下恒有此属性**——`cards` 是当前这一轮桌面上的牌,不属于「查牌」,屏蔽了后出的人就无从跟牌(design §9 末尾)|
| seatlist | 数组 | 三家座位牌况,与上文 chupai 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)| | seatlist | 数组 | 三家座位牌况,与上文 `maipai` / `chupai1/2/3` 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)|
| liangpai | json | **亮牌**,结构同 maipai 包的 `liangpai`(`{ cards: [牌id...] }`);**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到,design §8.2)| | liangpai | json | **亮牌**,结构同 maipai 包的 `liangpai`(`{ cards: [牌id...] }`);**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到,design §8.2)|
| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id,各自已按**本局主牌花色**从大到小排序。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 | | pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id,各自已按**本局主牌花色**从大到小排序。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 | | curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 |
+26 -1
View File
@@ -322,6 +322,19 @@ youle_erqiwang.maipai = function(pack){
msg.data.success = true; msg.data.success = true;
msg.data.seat = o_paiju.playproc.currseat; msg.data.seat = o_paiju.playproc.currseat;
msg.data.countdown = o_desk.method.get_countdown_chupai(); msg.data.countdown = o_desk.method.get_countdown_chupai();
if (!_cfg.nocheck){
//三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发——与 chupai1/2/3、重连包
//PushCards.seatlist 同一份数据、同一道门控。埋牌完成时它是刚初始化的空表
//(每家 [[0,0],[0,0],[0,0],[0,0],[-1,-1]]),本身不含任何信息;但「埋牌完成 →
//庄家首出」这段窗口内前端也要有表可画,漏发会让增量路径此刻为空、重连路径却有表。
//可见性:表内只有「某家缺某花色/某花色无对」(由公开的跟牌行为推出)与报无主后
//全场公示的余主数量(design §9 明确要求向全体三人展示),故三家整表下发不构成泄露
msg.data.seatlist = o_paiju.seatlist;
}
//埋牌底牌(本次埋下的 8 张):取服务端权威快照,【不回显客户端提交的 cards 原序】。
//get_burycard 与重连包 PushCards.burycards 是同一个函数、同一套按本局主牌花色的排序,
//两条路径因此顺序一致(SSOT);直接回显 cards 会把客户端的选牌顺序当成下发顺序。
var _burycards = cls_youle_erqiwang_paiju.get_burycard(o_paiju);
for (var i = 0; i < o_room.seatlist.length; i++) { for (var i = 0; i < o_room.seatlist.length; i++) {
msg.conmode = o_room.seatlist[i].conmode; msg.conmode = o_room.seatlist[i].conmode;
msg.fromid = o_room.seatlist[i].fromid; msg.fromid = o_room.seatlist[i].fromid;
@@ -329,7 +342,7 @@ youle_erqiwang.maipai = function(pack){
msg.data.cards = o_paiju.method.get_seat_cards(i); msg.data.cards = o_paiju.method.get_seat_cards(i);
//埋牌底牌(本次埋下的 8 张)。与 shangzhuang.bottomcards(底牌,发牌留桌 8 张) //埋牌底牌(本次埋下的 8 张)。与 shangzhuang.bottomcards(底牌,发牌留桌 8 张)
//是两批不同的牌,字段名刻意区分,勿混用 //是两批不同的牌,字段名刻意区分,勿混用
msg.data.burycards = cards; msg.data.burycards = _burycards;
delete msg.data.liangpai; delete msg.data.liangpai;
} else { } else {
delete msg.data.cards; delete msg.data.cards;
@@ -474,6 +487,16 @@ youle_erqiwang.chupai = function(pack){
msg.data.countdown = o_desk.method.get_countdown_chupai(); msg.data.countdown = o_desk.method.get_countdown_chupai();
//当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开 //当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开
msg.data.curmultiple = o_paiju.method.get_curmultiple(); msg.data.curmultiple = o_paiju.method.get_curmultiple();
//本轮进行态(design §5.1):与重连包 PushCards.playproc 同一个快照函数、同一结构。
//没有它,前端在非重连时就拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己
//累积推导——那就是把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。
//注意 re.idx==3 时 do_playcard 已在内部调用 new_playround 就地开了新一轮,
//故 chupai3 带的是【新一轮】的进行态(与 nextseat 同步),这与此刻重连拿到的完全一致。
//可见性:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard 都是
//由桌面公开信息直接推出的;cards 就是本轮已摊在桌上的牌;shuai_demand 是首家甩牌的
//分量构成,而甩出的牌本身已公开、chupai1 也已把等价的 shuai 下发给三家。全部对三家可见,
//无需逐座位裁剪(重连包 PushCards.playproc 本就对三家无差别下发,两条路径口径一致)
msg.data.playproc = cls_youle_erqiwang_paiju.get_playproc(o_paiju);
if (re.shuaicuo){ if (re.shuaicuo){
msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5) msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5)
} }
@@ -550,6 +573,8 @@ youle_erqiwang.chupai = function(pack){
delete msg.data.nextseat; delete msg.data.nextseat;
delete msg.data.countdown; delete msg.data.countdown;
delete msg.data.baozhu; delete msg.data.baozhu;
//本轮进行态只对「还要继续出牌」有意义;本包已转为 jiesuan,不带
delete msg.data.playproc;
delete msg.data.cardsinhand; delete msg.data.cardsinhand;
delete msg.data.maxseat; delete msg.data.maxseat;
delete msg.data.grade; delete msg.data.grade;
+129
View File
@@ -165,6 +165,135 @@ const cmZhuang = E.get_deskinfo(e.o_room, 0);
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改', t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true); cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true);
// ===================== 下发面一致性:增量路径(实时推送) 必须与 全量路径(重连 deskinfo) 给出同一份数据 =====================
// 背景:前端一致性测试(同一局真包,逐包增量回放 vs 一次性 deskinfo 重建)暴露出 4 处服务端下发面缺陷。
// 这些都不是"算错了",而是"同一份数据两条路径给的不一样 / 有一条根本没给",前端只能自己补——
// 而自己补就等于把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。以下逐条钉死。
const JS = x => JSON.stringify(x);
const AR = require('../class.arith.js'); // 本文件下方另有同模块的 A,此处在其声明之前,故另起名
// ---- ① bottomcards:上庄推送 / ChooseMain(step2) / BuryCards(step3) 三处必须【同序】----
// 曾经的缺陷:get_bottomcards 每次按"当时的 paiju.flower"重排,而底牌是在【选主之前】翻给庄家看的,
// 于是 step2(flower=-1) 与 step3(flower 已定) 给出两种顺序——主花色的那张牌被顶到队首。
// 现按发牌时冻结的快照下发。反面守卫:主牌花色刻意选成底牌里真实存在的花色,一旦退回"按当时 flower 排"必红。
function driveBottom(flowerPick) {
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 }));
const sz = c.sent.filter(m => m.rpc === 'shangzhuang').find(m => m.data.cards !== undefined).data; // 庄家包
const step2 = E.get_deskinfo(c.o_room, 0).ChooseMain;
mod.xuanzhu(pack(0, { flower: flowerPick(pj) }));
const step3 = E.get_deskinfo(c.o_room, 0).BuryCards;
return { pj, c, sz, step2, step3 };
}
// 主牌花色必须挑一个【会真的改变底牌排序】的:否则本组用例是空转的——
// 退回"按当时 flower 重排"也照样绿(实测:随手挑底牌里第一张的花色就是空转,它本来就排在队首)。
let btReorders = false;
const bt = driveBottom(pj => {
const snap = P.get_bottomcards(pj);
for (let f = 1; f <= 4; f++) {
if (JS(AR.order_cards(f, snap.concat())) !== JS(snap)) { btReorders = true; return f; }
}
return 1;
});
// 先钉住前提:所选花色确实会让"按主牌花色排"与"冻结快照"不同序,下面三条才是有效守卫
t.eq('底牌 用例前提:所选主牌花色会改变排序(否则本组用例空转)', btReorders, true);
t.eq('底牌 上庄推送 8 张', bt.sz.bottomcards.length, 8);
t.eq('底牌 上庄推送 == 重连 ChooseMain(step2) 同序', JS(bt.step2.bottomcards), JS(bt.sz.bottomcards));
t.eq('底牌 上庄推送 == 重连 BuryCards(step3) 同序(选主后不得重排)', JS(bt.step3.bottomcards), JS(bt.sz.bottomcards));
// 快照必须是【副本】:order_cards 是原地排序,调用方就地重排不得污染冻结的快照
const bcCopy = P.get_bottomcards(bt.pj); bcCopy.reverse();
t.eq('底牌 get_bottomcards 返回副本(调用方重排不污染快照)', JS(P.get_bottomcards(bt.pj)), JS(bt.sz.bottomcards));
// ---- ② curmultiple:ChooseMain(step2) / BuryCards(step3) 必须与 shangzhuang 同源同值 ----
// 此刻一张牌未出、捡分恒为 0 → 必为 +3(大光)。曾经 deskinfo 不带此字段,重连后前端角标掉回 0。
t.eq('抓分倍数 上庄推送 curmultiple=3(捡分恒0→大光)', bt.sz.curmultiple, 3);
t.eq('抓分倍数 重连 ChooseMain.curmultiple 与上庄同值', bt.step2.curmultiple, bt.sz.curmultiple);
t.eq('抓分倍数 重连 BuryCards.curmultiple 与上庄同值', bt.step3.curmultiple, bt.sz.curmultiple);
// ---- ③ maipai.seatlist:与 PushCards.seatlist 同表同门控 ----
// 曾经的缺陷:maipai 不带 seatlist,「埋牌完成 → 庄家首出」这段窗口增量路径为空、重连路径却有表。
const mpZhuang3 = mpAll.find(m => m.data.cards !== undefined);
const mpXian3 = mpAll.find(m => m.data.cards === undefined);
t.eq('埋牌 可查牌 maipai 带 seatlist(庄闲同表)',
JS(mpZhuang3.data.seatlist), JS(E.get_deskinfo(e.o_room, 0).PushCards.seatlist));
t.eq('埋牌 可查牌 maipai 闲家同一张表(seatlist 三家公开、不裁剪)',
JS(mpXian3.data.seatlist), JS(mpZhuang3.data.seatlist));
t.eq('埋牌 seatlist 埋牌完成时为初始化空表', JS(mpZhuang3.data.seatlist),
JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]])));
// 反面:不查牌模式一律不下发(与 chupai / PushCards 同一道门控,design §9)
t.eq('埋牌 不查牌 maipai 无 seatlist',
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.seatlist === undefined), true);
// ---- ④ burycards:maipai 必须发服务端权威快照,不得回显客户端提交的 cards 原序 ----
// 曾经的缺陷:msg.data.burycards = cards(请求包原样回显),而 PushCards.burycards 走 get_burycard()
// 按主牌花色排序 —— 同一副埋牌底牌两条路径两种顺序(SSOT)。
const bu = (() => {
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 }));
// 刻意打乱提交顺序:手牌是从大到小的,取 8 张后【反转】成从小到大提交
const raw = P.get_seat_cards(pj, 0).slice(0, 8).reverse();
mod.maipai(pack(0, { cards: raw }));
const mp = c.sent.filter(m => m.rpc === 'maipai').find(m => m.data.burycards !== undefined);
return { pj, c, raw, mp };
})();
t.eq('埋牌底牌 maipai == 重连 PushCards 同序(SSOT)',
JS(bu.mp.data.burycards), JS(E.get_deskinfo(bu.c.o_room, 0).PushCards.burycards));
t.eq('埋牌底牌 maipai 不回显客户端提交的原序', JS(bu.mp.data.burycards) === JS(bu.raw), false);
t.eq('埋牌底牌 maipai 与提交的是同一批牌(只是顺序不同)',
JS(bu.mp.data.burycards.concat().sort((x, y) => x - y)), JS(bu.raw.concat().sort((x, y) => x - y)));
// ---- ⑤ chupai1/2/3.playproc:结构与取值必须与同一时刻的 PushCards.playproc 完全一致 ----
// 没有它,前端在非重连时拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己累积推导。
// 用真实牌局打满 28 轮,每出一手就拿三家的下发包与三家的重连快照逐一比对。
const pp = (() => {
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 }));
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出活引用问题。
// 故在其之外再挂一层,原样留住服务端交出来的那个 playproc 对象。
const live = [];
const innerSend = mod.app.SendPack;
mod.app.SendPack = m => { if (/^chupai[123]$/.test(m.rpc)) live.push(m.data.playproc); innerSend(m); };
let cmp = 0, mism = 0, missing = 0, rounds = 0, guard = 0, jiesuanHasPlayproc = null, firstSnap = null;
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;
c.sent.length = 0;
mod.chupai(pack(seat, { cards: pick }));
const cps = c.sent.filter(m => /^chupai[123]$/.test(m.rpc));
const js = c.sent.filter(m => m.rpc === 'jiesuan');
if (js.length) jiesuanHasPlayproc = js.some(m => m.data.playproc !== undefined);
if (!cps.length) continue;
rounds++;
if (cps.some(m => m.data.playproc === undefined)) missing++;
if (firstSnap === null) firstSnap = JS(live[0]); // 第 1 包发出【当时】的样子
if (pj.step !== 5) continue; // 已进结算,无"当前进行态"可比
for (let s = 0; s < 3; s++) {
const rec = E.get_deskinfo(c.o_room, s).PushCards.playproc;
cmp++;
if (JS(cps[s].data.playproc) !== JS(rec)) mism++;
}
}
mod.app.SendPack = innerSend;
return { pj, cmp, mism, missing, rounds, jiesuanHasPlayproc, firstSnap, firstRef: live[0] };
})();
t.eq('本轮进行态 28轮打完(用例真的走到底)', pp.pj.playproc.round, 28);
t.eq('本轮进行态 chupai1/2/3 恒带 playproc', [pp.rounds > 80, pp.missing], [true, 0]);
t.eq('本轮进行态 增量下发 == 同时刻重连快照(逐包逐座位)', [pp.cmp > 200, pp.mism], [true, 0]);
t.eq('本轮进行态 chupai3 转 jiesuan 时不带 playproc', pp.jiesuanHasPlayproc, false);
// 深拷贝守卫:playproc 是全程复用的活对象(new_playround 就地重置同一个对象)。
// 若下发挂的是活引用,打完整局后"第一包里的第 1 轮进行态"会变成最后一轮的样子。
t.eq('本轮进行态 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(pp.firstRef), pp.firstSnap);
t.eq('本轮进行态 第1包就是第1轮、cards 定长3', [pp.firstRef.round, pp.firstRef.cards.length], [1, 3]);
// ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)===================== // ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)=====================
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法 // 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
function mkShuai(extraToSeat1) { function mkShuai(extraToSeat1) {