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) {