diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js
index 6525528..2b98d98 100644
--- a/server/games/erqiwang/class.export.js
+++ b/server/games/erqiwang/class.export.js
@@ -6,6 +6,7 @@ if (typeof require !== "undefined"){
var cls_youle_erqiwang_config = require("./class.config.js");
var cls_youle_erqiwang_arith = require("./class.arith.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 || {
@@ -112,6 +113,10 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
} else {
deskinfo.ChooseMain.touxiang = 1;
}
+ //当前抓分倍数(design §7.2.0):与 shangzhuang 推送同源同值。
+ //选主阶段一张牌都还没出,捡分恒为 0 → 必为 +3(大光);
+ //漏发会让重连后顶部「抓分」角标掉回 0,与增量路径不一致
+ deskinfo.ChooseMain.curmultiple = paiju.method.get_curmultiple();
break;
case 3: //埋牌
deskinfo.BuryCards = {};
@@ -129,6 +134,8 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
}
//主牌花色
deskinfo.BuryCards.flower = paiju.flower;
+ //当前抓分倍数(design §7.2.0):埋牌阶段同样一张牌未出,捡分恒为 0 → 必为 +3
+ deskinfo.BuryCards.curmultiple = paiju.method.get_curmultiple();
//埋牌阶段无投降(投降是选主阶段与选主互斥的选择)
break;
case 5: //出牌
@@ -157,8 +164,9 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
deskinfo.PushCards.grade = jian_grade.grade;
//当前抓分倍数(design §7.2.0):与 chupai 包同源同值,重连后角标立即正确
deskinfo.PushCards.curmultiple = paiju.method.get_curmultiple();
- //当前的出牌情况
- deskinfo.PushCards.playproc = paiju.playproc;
+ //当前的出牌情况:与 chupai1/2/3 共用同一处快照函数(结构一致、且是深拷贝,
+ //不把活对象挂进下发包,见 class.paiju.js get_playproc)
+ deskinfo.PushCards.playproc = cls_youle_erqiwang_paiju.get_playproc(paiju);
//本轮跟牌的必出牌(design §5.2):只在【轮到本座位】且本轮已有人出牌时才有,
//算的也只是本座位自己的手牌,不涉及他家(server 红线:按可见性下发)
if (paiju.playproc && paiju.playproc.currseat == seat){
diff --git a/server/games/erqiwang/class.paiju.js b/server/games/erqiwang/class.paiju.js
index e37e816..56b0f2b 100644
--- a/server/games/erqiwang/class.paiju.js
+++ b/server/games/erqiwang/class.paiju.js
@@ -116,6 +116,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
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(){
paiju.step = 1;
@@ -149,6 +167,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
paiju.call = -1;
//主牌花色
paiju.flower = -1;
+ //底牌快照(发牌留桌的 8 张,含显示顺序):发牌结束时冻结,见 init_bottomcards
+ paiju.bottomcards = [];
//当前出牌情况
paiju.playproc = {};
//输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散
@@ -160,6 +180,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
init_seatlist();
//初始化牌列表并发牌
init_cards();
+ //冻结底牌快照(必须在发牌之后、选主之前)
+ init_bottomcards();
//进入叫分阶段
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){
- var aryCardIDs = [];
- for (var i = 0; i < o_paiju.cards.length; i++){
- if (o_paiju.cards[i].dealowner == 0){
- aryCardIDs.push(o_paiju.cards[i].id);
+ return o_paiju.bottomcards.concat();
+ },
+
+ //获取【供下发的】当前出牌情况快照: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;
},
//选主
diff --git a/server/games/erqiwang/docs/protocol/packet_protocol.md b/server/games/erqiwang/docs/protocol/packet_protocol.md
index 8e21c40..2c7ec9d 100644
--- a/server/games/erqiwang/docs/protocol/packet_protocol.md
+++ b/server/games/erqiwang/docs/protocol/packet_protocol.md
@@ -183,7 +183,7 @@
| banker | 整数 | 庄家的位置序号 |
| 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)|
-| bottomcards | 数组 | 8 张**底牌**(发牌时没发给玩家、扣在桌面的 8 张)。**庄家恒有**;**闲家仅当 70 分坐庄时才有**(供 3 秒亮牌用),非 70 分时闲家没有该属性(底牌只有庄家可见,design §4)|
+| 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 分无此属性 |
| cards | 数组 | 拿了底牌后手上的牌(36 张,含底牌),庄家才有此属性,闲家没有该属性 |
| countdown | 整数 | 选主倒计时 |
@@ -256,7 +256,8 @@
| 参数名 | 类型 | 说明 |
| --- | --- | --- |
| cards | 数组 | 埋牌后手上的牌,去掉了埋牌,庄家才有此属性,闲家没有该属性 |
-| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),庄家才有此属性,闲家没有该属性。注意与 `shangzhuang.bottomcards`(**底牌**,发牌留桌 8 张)是两批不同的牌,见 §0.0 |
+| 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]]`)——下发它是为了让「埋牌完成 → 庄家首出」这段窗口内,增量路径与重连路径拿到同一张表,客户端无需为这段窗口特判 |
| seat | 整数 | 出牌者的位置序号(即将首出的庄家)|
| 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 张,但包含之后已打出的牌),全局固定不随出牌缩水,故重连包里取值一致 |
@@ -297,6 +298,7 @@
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
| cardsinhand | 数组 | 出牌者出牌后手上剩下的牌id列表,只有出牌者才有此属性 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与结算包 `aset.upgrade` 同口径(`3`/`2`/`1` 庄家大光/小光/过庄,`-N` 闲家升 N 级,`0` 叫分未定)。同源同算法,差别仅在此处**不含扣底**。三家同值,随本包下发、不另开推送。符号是「谁赢」的区分,客户端判定动画靠它分辨 |
+| playproc | json | **本轮进行态**(design §5.1)。**`chupai1/2/3` 三个包恒有,三家同值**;结构与重连包 [`PushCards.playproc`](#断线重连deskinfo) **完全一致**(服务端同一个快照函数 `get_playproc()`,前端增量回放与重连重建复用同一份解析)。
字段:`round` 第几轮、`start` 本轮首出位置、`currseat` 当前该谁出、`startcount` 首出张数、`startflower` 首出花色、`starttype` 首出牌型、`maxseat` 本轮暂时最大者、`maxcard` 其牌编码、`cards` 本轮三家各自出的牌(**定长 3,下标 = 座位序号**,未出的位置为 `null`)、`shuai_demand` 首家甩牌的分量需求 `{tractors:[连对数...],pairs,singles}`(非甩牌为 `null`)。
⚠️ **`chupai3` 带的是【下一轮】的进行态**(`round+1`、`cards` 全空、`currseat == nextseat == maxseat`):本轮第三家一出完,服务端就地开了新一轮。这与「此刻断线重连拿到的 `PushCards.playproc`」完全相同——本轮那三手牌客户端已由 `chupai1/2/3` 各自的 `seat`+`cards` 收到,收牌动画后即清台。**唯一例外**:`chupai3` 打完最后一张牌时本包会转成 `jiesuan`(见 §14),那种情况下**不带** `playproc`。
**可见性**:全部字段都由桌面公开信息推出(`cards` 就是已摊在桌上的牌,`shuai_demand` 与 `chupai1.shuai` 等价且甩出的牌本身已公开),故三家整体下发、不逐座位裁剪 |
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**(甩牌跟牌走逐分量匹配,见 design §5.4.4)、或算出的必出牌为空 |
---
@@ -314,6 +316,7 @@
| countdown | 整数 | 出牌倒计时 |
| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是;对应服务端 `have_baofu()`(任一玩家 `seatlist[seat][4][0]==0`)。**仅可查牌模式**下才可能为 1,是**余主公示**与"明牌"按钮的开关;**不查牌模式恒为 0**(design §9)|
| cardsinhand | 数组 | 出牌后出牌者手上剩下的牌id列表,只有出牌者才有此属性 |
+| playproc | json | **本轮进行态**,恒有、三家同值,结构见 [§11 `playproc`](#11-第一个玩家出牌chupai1)。此时 `cards` 已含首家与本家两手牌,`currseat` 指向第三家 |
| mustcard | 数组 | **下一个出牌者本轮跟牌的必出牌**(design §5.2),供其客户端自动选中。**只发给 `nextseat` 那一家**,其余两家无此属性——它是该玩家自己手牌的子集,整表下发会泄露他家手牌结构。以下情形不下发:`nextseat` 是本轮首家、首家为**甩牌**、或算出的必出牌为空 |
---
@@ -334,6 +337,7 @@
| maxseat | 整数 | 本轮出牌谁最大,只有本轮最后一个玩家出牌后才有此属性 |
| grade | 整数 | 本轮闲家得分,只有本轮最后一个玩家出牌后且闲家有得分才有此属性 |
| 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 | 整数 | 叫分 |
| 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 | 整数 | 选主倒计时 |
-| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)|
+| bottomcards | 数组 | 8 张**底牌**(发牌留桌的 8 张),**只有庄家有此属性**(底牌仅庄家可见;70 分的 3 秒亮牌是上庄时的一次性事件,重连不重放)。顺序取发牌时冻结的快照,与 `shangzhuang.bottomcards`、`BuryCards.bottomcards` **完全同序**(见 §4)|
| touxiang | 整数 | 是否允许投降 0:不允许 1:允许(仅 70 分坐庄为 1)。投降与选主互斥、同一决策点,见 touxiang 包与 design §4 |
+| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `shangzhuang.curmultiple` 同源同值。选主阶段一张牌都还没出、捡分恒为 0,故**必为 `3`**(大光)。三家同值。有此字段,重连后顶部「抓分」角标才不会掉回 0 |
> 选主阶段前端需在每个花色按钮上显示"该花色在庄家手中的对子数"(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)|
| flower | 整数 | 主牌花色 |
| 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 与选主互斥的选择,选主后即不可再投降)。
@@ -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)|
| flower | 整数 | 主牌花色 |
| countdown | 整数 | 出牌倒计时 |
-| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0 |
+| burycards | 数组 | **埋牌底牌**(庄家埋下的 8 张),只有庄家有此属性。注意与 `ChooseMain`/`BuryCards.bottomcards`(**底牌**)是两批不同的牌,见 §0.0。与 `maipai.burycards` **同源同序**(同一个 `get_burycard()`,按本局主牌花色从大到小)|
| grade | 整数 | 当前的捡分分数 |
| 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 末尾)|
-| seatlist | 数组 | 三家座位牌况,与上文 chupai 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(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 | 数组 | 三家座位牌况,与上文 `maipai` / `chupai1/2/3` 包的 `seatlist` 同名同结构(每个玩家一个 5 元素数组:4 个花色的 `[无该花色,无对]` + 报副 `[剩余主牌数,剩余主对数]`)。**仅可查牌模式下有此属性**(design §9)|
| liangpai | json | **亮牌**,结构同 maipai 包的 `liangpai`(`{ cards: [牌id...] }`);**仅可查牌模式、且请求者为闲家、且庄家达标时有**(供闲家重连后仍能看到,design §8.2)|
| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id,各自已按**本局主牌花色**从大到小排序。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 |
| curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 |
diff --git a/server/games/erqiwang/mod.js b/server/games/erqiwang/mod.js
index 487d885..240d2aa 100644
--- a/server/games/erqiwang/mod.js
+++ b/server/games/erqiwang/mod.js
@@ -322,6 +322,19 @@ youle_erqiwang.maipai = function(pack){
msg.data.success = true;
msg.data.seat = o_paiju.playproc.currseat;
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++) {
msg.conmode = o_room.seatlist[i].conmode;
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);
//埋牌底牌(本次埋下的 8 张)。与 shangzhuang.bottomcards(底牌,发牌留桌 8 张)
//是两批不同的牌,字段名刻意区分,勿混用
- msg.data.burycards = cards;
+ msg.data.burycards = _burycards;
delete msg.data.liangpai;
} else {
delete msg.data.cards;
@@ -474,6 +487,16 @@ youle_erqiwang.chupai = function(pack){
msg.data.countdown = o_desk.method.get_countdown_chupai();
//当前抓分倍数(design §7.2.0):随本包一起下发、不另开推送;三家同值,grade 本就公开
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){
msg.data.shuaicuo = 1; //甩错:本次甩牌被收回,只打出了最小一张(§5.4.5)
}
@@ -550,6 +573,8 @@ youle_erqiwang.chupai = function(pack){
delete msg.data.nextseat;
delete msg.data.countdown;
delete msg.data.baozhu;
+ //本轮进行态只对「还要继续出牌」有意义;本包已转为 jiesuan,不带
+ delete msg.data.playproc;
delete msg.data.cardsinhand;
delete msg.data.maxseat;
delete msg.data.grade;
diff --git a/server/games/erqiwang/test/test_rpc.js b/server/games/erqiwang/test/test_rpc.js
index 0292d2d..5503dd4 100644
--- a/server/games/erqiwang/test/test_rpc.js
+++ b/server/games/erqiwang/test/test_rpc.js
@@ -165,6 +165,135 @@ const cmZhuang = E.get_deskinfo(e.o_room, 0);
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
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 表达不了甩牌)=====================
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
function mkShuai(extraToSeat1) {