diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js index 2b98d98..a6b5efb 100644 --- a/server/games/erqiwang/class.export.js +++ b/server/games/erqiwang/class.export.js @@ -175,6 +175,12 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { deskinfo.PushCards.mustcard = _must; } } + //报无主:与 chupai1/2/3 的 baozhu 同源同门控(mod.chupai)。 + //【必须带】它是「余主公示 + 明牌按钮」的开关(design §9,前端 + //Sprites_Table.js 的明牌按钮据此显隐)。只在 chupai 包里给、重连包不给, + //等于重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。 + //不查牌模式恒 0(与 chupai 一致,协议 §11–§13 baozhu 条目) + deskinfo.PushCards.baozhu = (!cfg.nocheck && paiju.method.have_baofu()) ? 1 : 0; //座位列表(报副统计)与亮牌:仅可查牌模式下展示(§9/§8.2) if (!cfg.nocheck){ deskinfo.PushCards.seatlist = paiju.seatlist; @@ -189,36 +195,12 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || { //不查牌模式下不带此属性——当前这一轮桌面上的牌由上面的 playproc 恢复,两种模式都有, //否则后出的人无从跟牌(design §9 末尾的边界说明) if (!cfg.nocheck){ - deskinfo.PushCards.pushlist = []; - for (var i = 0; i < paiju.cards.length; i++) { - var pai = paiju.cards[i]; - if (pai.playround > 0){ - if (deskinfo.PushCards.pushlist.length < pai.playround){ - deskinfo.PushCards.pushlist.length = pai.playround; - } - if (!deskinfo.PushCards.pushlist[pai.playround - 1]){ - deskinfo.PushCards.pushlist[pai.playround - 1] = [[], [], []]; - } - - var owner = pai.dealowner; - if (owner == 0){ - owner = paiju.banker + 1; - } - deskinfo.PushCards.pushlist[pai.playround - 1][owner - 1].push(i); - } - } - //按大小排序:外层下标=轮次、内层恒为 3 个座位;排序须按本局主牌花色 - //(内层曾误用轮次数作上界,会把内层数组撑成轮次长度并填入 undefined; - // 且曾误用上一个循环泄漏出来的 pai.flower——那是最后一张牌大王的花色 5, - // 等于按「无主牌」排序) - for (var i = 0; i < deskinfo.PushCards.pushlist.length; i++) { - if (!deskinfo.PushCards.pushlist[i]){ - continue; - } - for (var j = 0; j < 3; j++) { - deskinfo.PushCards.pushlist[i][j] = cls_youle_erqiwang_arith.order_cards(paiju.flower, deskinfo.PushCards.pushlist[i][j]); - } - } + //【直读归档、不重建】pushlist 的唯一来源是 do_playcard 出牌当场写下的 + //paiju.playhistory(见 class.paiju.js)。旧实现是从 paiju.cards 的 playround/dealowner + //反查再自己排一次序——那是同一份数据「这一手打出的牌」的第二个写入处: + //一手多张(甩牌/对子/拖拉机)时它与 chupai1/2/3 的 data.cards 会给出不同顺序, + //前端增量回放与重连重建因此对不上(同编码的一对牌尤其:排序无法区分它们)。 + deskinfo.PushCards.pushlist = paiju.method.get_pushlist(); } break; case 6: //结算 diff --git a/server/games/erqiwang/class.paiju.js b/server/games/erqiwang/class.paiju.js index 56b0f2b..95e43bb 100644 --- a/server/games/erqiwang/class.paiju.js +++ b/server/games/erqiwang/class.paiju.js @@ -171,6 +171,13 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { paiju.bottomcards = []; //当前出牌情况 paiju.playproc = {}; + //出牌历史归档:外层下标 = 轮次-1,内层恒 3 个数组、下标 = 座位;每一项就是那一手 + //【实际打出、且已归一成权威顺序】的牌。唯一写入处是 do_playcard 的 doplay()。 + //【为什么要存】重连包 PushCards.pushlist 需要往轮的出牌历史,而牌一旦落地就只剩 + //「第几轮 + 属于谁」(cards[i].playround/dealowner),一手之内的顺序无从恢复—— + //从那里反查就必须自己再排一次序,那是同一份数据的第二个写入处(SSOT 违反), + //且遇到同编码的两张牌(一对)时两处必然分岔。改为出牌当场归档、下游只读。 + paiju.playhistory = []; //输赢结果 0:庄赢 1:闲赢 2:庄投降 3:中途解散 paiju.result = null; //结算包,供断线重连时使用,新开一局时清除 @@ -283,6 +290,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { return cls_youle_erqiwang_paiju.get_curmultiple(paiju); } + //获取出牌历史快照(重连包 PushCards.pushlist) + paiju.method.get_pushlist = function(){ + return cls_youle_erqiwang_paiju.get_pushlist(paiju); + } + //获取某座位本轮跟牌的必出牌(design §5.2) paiju.method.get_mustcard = function(seat){ return cls_youle_erqiwang_paiju.get_mustcard(paiju, seat); @@ -480,6 +492,24 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { //随后续出牌被回改;平台真实链路虽然立刻序列化看不出来,但这是隐患,不留。 //structure:round/start/currseat/startcount/startflower/starttype/maxseat/maxcard/ // cards(定长3、下标=座位)/shuai_demand + //获取【供下发的】出牌历史快照(重连包 PushCards.pushlist)。 + //唯一来源是 do_playcard 当场归档的 playhistory,这里只拷贝、不重算也不重排: + //它与 chupai1/2/3 的 data.cards、playproc.cards 是同一次 order_playcards 的产物, + //因此两条路径逐元素相等(前端增量回放与重连重建必定一致)。 + //【返回深拷贝】不把活数组挂进下发包(同 get_playproc)。 + get_pushlist: function(o_paiju){ + var re = []; + for (var i = 0; i < o_paiju.playhistory.length; i++){ + var _round = o_paiju.playhistory[i]; + var _one = []; + for (var s = 0; s < 3; s++){ + _one.push(_round[s].concat()); + } + re.push(_one); + } + return re; + }, + get_playproc: function(o_paiju){ var _p = o_paiju.playproc; if (!_p){ @@ -602,6 +632,23 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { o_paiju.playproc.shuai_demand= null; //首家甩牌的分量需求(供跟牌逐分量匹配),非甩牌为null }, + //【一手出牌的顺序:唯一口径】把一手牌规整成本局的权威展示顺序 + //(按主牌花色从大到小)。只在 do_playcard 落牌那一刻调一次,之后全体下游只读: + // · playproc.cards[座位] → chupai1/2/3 与重连包的 playproc + // · do_playcard 返回的 re.cards → chupai1/2/3 的 data.cards + // · playhistory[轮次][座位] → 重连包的 PushCards.pushlist + //三者同源同数组内容,所以前端“增量回放”与“重连重建”必定逐元素相等。 + // + //为什么不能用客户端提交的原序:那是玩家的点击顺序,同一手牌每次都可能不同, + //服务端不得依赖、也不回显(server 04 §8:前端不是数据源)。 + //【返回新数组】——order_cards 是原地排序,直接排入参会就地改写调用方(含 pack.data.cards)。 + order_playcards: function(o_paiju, cards){ + if (!cards){ + return cards; + } + return cls_youle_erqiwang_arith.order_cards(o_paiju.flower, cards.concat()); + }, + //在牌列表中获取分牌和分值 get_grade_incard: function(o_paiju, cards){ var re = {}; @@ -618,6 +665,12 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { //检查选中的牌是否可出,并出牌 do_playcard: function(o_paiju, cards){ + //【顺序归一:唯一写入处】入参是客户端提交的点击顺序,服务端不采信、不下发。 + //在这里一次性转成权威顺序(order_playcards),此后 playproc.cards / 返回给下发包的 + //re.cards / 重连包 pushlist 全都是同一个口径,不会再有第二处排序(SSOT)。 + //顺带隔离了 can_playcard 的【原地排序】——否则它会就地改写 pack.data.cards。 + cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, cards); + //出牌 var doplay = function(playindex){ for (var i = 0; i < cards.length; i++) { @@ -625,6 +678,11 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { o_paiju.cards[cards[i]].playindex = playindex; }; o_paiju.playproc.cards[o_paiju.playproc.currseat] = cards; + //归档这一手,供重连包 PushCards.pushlist 直读(唯一写入处,见 paiju.playhistory) + while (o_paiju.playhistory.length < o_paiju.playproc.round){ + o_paiju.playhistory.push([[], [], []]); + } + o_paiju.playhistory[o_paiju.playproc.round - 1][o_paiju.playproc.currseat] = cards.concat(); //进入出牌阶段 o_paiju.step = 5; @@ -676,7 +734,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { if (can.shuaicuo){ //甩错惩罚(§5.4.5):收回甩牌,本轮只强制打出最小一张 _shuaicuo = true; - cards = [can.smallest]; + cards = cls_youle_erqiwang_paiju.order_playcards(o_paiju, [can.smallest]); can = cls_youle_erqiwang_arith.can_playcard(o_paiju.flower, cards, o_paiju.playproc.currseat, o_paiju.seatlist, opp_zhulist); if (!can.result){ var reErr = {}; @@ -707,7 +765,10 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { re1.count = cards.length; re1.flower = can.flower; re1.cardtype = can.cardtype; - re1.cards = cards; //实际打出的牌(甩错时为单张) + //实际打出的牌(甩错时为单张),已是权威顺序。 + //【给副本】cards 本体已挂在 playproc.cards[座位] 上,直接交出去会让「已发出的包」 + //随后续改动被回改(同 get_playproc 的深拷贝理由) + re1.cards = cards.concat(); re1.shuaicuo = _shuaicuo; //合法甩牌的分量构成:cardtype 只能表达单一牌型,甩牌(单张/对子/拖拉机自由混搭, //design §5.4.3)会被压平成 1xx/2xx,客户端无从据此还原分组,故另行下发分量需求 @@ -759,6 +820,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { var re2 = {}; re2.idx = 2; re2.result = true; + re2.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards) return re2; } @@ -776,6 +838,7 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || { var re3 = {}; re3.result = true; re3.idx = 3; + re3.cards = cards.concat(); //实际打出的牌,权威顺序(理由同 re1.cards) re3.maxseat = o_paiju.playproc.maxseat; if (o_paiju.playproc.maxseat != o_paiju.banker){ re3.grade = 0; diff --git a/server/games/erqiwang/docs/protocol/packet_protocol.md b/server/games/erqiwang/docs/protocol/packet_protocol.md index 0bd3f59..d754c3b 100644 --- a/server/games/erqiwang/docs/protocol/packet_protocol.md +++ b/server/games/erqiwang/docs/protocol/packet_protocol.md @@ -126,6 +126,27 @@ | AA 每人 `'1'` | 6 局 | 1 张 | 1 张 | | AA 每人 `'1'` | 12 局 | 2 张 | 2 张 | +### 0.6 「一手出牌」的顺序口径 + +同一份业务数据「某一手打出的牌」会出现在**三个下发位置**: + +| 位置 | 字段 | +|---|---| +| 出牌推送 | `chupai1` / `chupai2` / `chupai3` 的 `data.cards` | +| 本轮进行态 | `playproc.cards[座位]`(`chupai*` 与重连包 `PushCards` 都带)| +| 重连出牌历史 | `PushCards.pushlist[轮次-1][座位]` | + +**这三处的数组内容逐元素完全相等**——顺序统一为「按**本局主牌花色**从大到小」的**权威顺序**。 +服务端只在落牌那一刻归一化一次(`class.paiju.js` 的 `order_playcards`),随后: +出牌推送读它、`playproc.cards` 存它、出牌历史 `playhistory` 归档它,下游一律**只读、不重排**。 + +因此前端**「增量回放」与「重连重建」得到的出牌历史必定一致**,两条路径可以复用同一份解析。 + +> ⚠️ **请求包里的 `cards` 顺序无意义**:那是玩家的点击顺序,同一手牌每次都可能不同。 +> 服务端不采信、不回显、也不据它判定牌型(server dev-guide 04 §8「前端不是数据源」)。 +> 一手单张时看不出差别;**一手多张(甩牌 / 对子 / 拖拉机)时才会显形**,尤其是同编码的一对牌 +> ——排序本身区分不了它们,只有「出牌当场归档」这一个写入处才能保证两条路径给出同一个数组。 + --- ## 1. 发牌(fapai) @@ -287,7 +308,7 @@ | 参数名 | 类型 | 说明 | | --- | --- | --- | | seat | 整数 | 出牌者的位置序号 | -| cards | 数组 | 实际打出的牌id列表;**甩错时**(见 `shuaicuo`)为被强制打出的那一张最小主牌单张 | +| cards | 数组 | 实际打出的牌id列表;**甩错时**(见 `shuaicuo`)为被强制打出的那一张最小主牌单张。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序——请求包里的 `cards` 顺序服务端不采信、不回显(见 [§0.6](#06-一手出牌的顺序口径))| | shuaicuo | 整数 | 甩错标志,仅甩错时出现且为 `1`(design §5.4.5:甩牌未通过最大性判定,整套甩牌收回,本轮只强制打出最小一张、失去本轮甩牌资格);正常出牌无此属性 | | seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` | | count | 整数 | 出牌数量(甩错时为 1) | @@ -311,7 +332,7 @@ | 参数名 | 类型 | 说明 | | --- | --- | --- | | seat | 整数 | 出牌者的位置序号 | -| cards | 数组 | 出的牌id列表 | +| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))| | seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` | | nextseat | 整数 | 下一个出牌者的位置序号 | | countdown | 整数 | 出牌倒计时 | @@ -329,7 +350,7 @@ | 参数名 | 类型 | 说明 | | --- | --- | --- | | seat | 整数 | 出牌者的位置序号 | -| cards | 数组 | 出的牌id列表 | +| cards | 数组 | 出的牌id列表。**顺序:按本局主牌花色从大到小(权威顺序)**,不是客户端提交时的点击顺序(见 [§0.6](#06-一手出牌的顺序口径))| | seatlist | 数组 | 三家座位牌况 `o_paiju.seatlist`(长度 3,下标 = 座位序号),**仅可查牌模式下发**(不查牌无此属性,design §9)。每个座位为 5 元素数组:前 4 个对应花色1~4 的 `[无该花色标志, 该花色无对标志]`(0/1),第 5 个为**余主公示**数据 `[剩余主牌数, 剩余主对数]`(初始 `[-1,-1]`,代码内旧称「报副」)。**一旦全场有人报无主,服务端会按各家实际手牌同时刷新三个座位并整表下发**(design §9「为全体三人显示另外两家」),无需等另两家各自出牌才补。字段名与结构同重连包 `PushCards.seatlist` | | nextseat | 整数 | 下一个出牌者的位置序号 | | countdown | 整数 | 出牌倒计时 | @@ -606,9 +627,10 @@ | grade | 整数 | 当前的捡分分数 | | gradecards | 数组 | 当前的捡分分牌,只有闲家有此属性 | | 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 末尾)| +| baozhu | 整数 | 是否已有玩家报无主(主牌出空)0否 1是,**恒有此属性**;与 `chupai1/2/3` 的 `baozhu` **同源同值同门控**(同一个 `have_baofu()` + 同一个查牌位)。它是**余主公示**与“明牌”按钮的开关,重连必须一并恢复,否则重连后按钮凭空消失;**不查牌模式恒为 0**(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` 恢复,**两种模式下都有**,否则后出的人无从跟牌 | +| pushlist | 数组 | 出牌历史 `[[[], [], []], [[], [], []], ...]`,外层下标=轮次(从第 1 轮起,含进行中的当前轮),内层**恒为 3 个数组**、按位置序号存该轮各家出的牌id。**每一手与当时那个 `chupai1/2/3` 包的 `cards` 逐元素完全相等**(同一份数据、同一个顺序口径,见 [§0.6](#06-一手出牌的顺序口径)),因此前端“增量回放”与“重连重建”得到的出牌历史必定一致。**仅可查牌模式下有此属性**——往轮打出、已被收走的牌属于「查牌」范畴,不查牌模式一律不下发(design §9)。注意当前这一轮桌面上的牌由 `playproc.cards` 恢复,**两种模式下都有**,否则后出的人无从跟牌 | | curmultiple | 整数 | **当前抓分倍数**(design §7.2.0):**带符号**,与 `chupai1/2/3` 同源同值,供重连后顶部「抓分」角标与判定动画状态立即正确 | | mustcard | 数组 | **本轮跟牌的必出牌**(design §5.2)。**仅当 `playproc.currseat == 请求者座位` 时才有**,且只算请求者自己的手牌;未轮到本家、本轮首家、甩牌局面、必出牌为空时均无此属性 | diff --git a/server/games/erqiwang/mod.js b/server/games/erqiwang/mod.js index d580d7b..ff2d7f9 100644 --- a/server/games/erqiwang/mod.js +++ b/server/games/erqiwang/mod.js @@ -481,7 +481,11 @@ youle_erqiwang.chupai = function(pack){ msg.data = {}; msg.data.success = true; msg.data.seat = seat; - msg.data.cards = re.cards ? re.cards : cards; //实际打出的牌(甩错时为单张) + //实际打出的牌(甩错时为单张),顺序由 do_playcard 的 order_playcards 统一给出—— + //与 playproc.cards、重连包 PushCards.pushlist 同一个口径(SSOT)。 + //【不回落到入参 cards】那是客户端的点击顺序,服务端不采信、更不回显(server 04 §8); + //do_playcard 成功时三个分支都会给出 re.cards,缺了就是它的缺陷,显式暴露、不兜底。 + msg.data.cards = re.cards; if (!_cfg.nocheck){ //三家座位牌况(缺门/无对 + 报副统计),仅可查牌下发。 //必须整表下发、不能只带出牌者一家:design §9 要求「一旦有玩家报无主,就为全体三人 diff --git a/server/games/erqiwang/test/test_hint.js b/server/games/erqiwang/test/test_hint.js index 44081f7..4baa64d 100644 --- a/server/games/erqiwang/test/test_hint.js +++ b/server/games/erqiwang/test/test_hint.js @@ -122,7 +122,7 @@ function mkFollow() { const pj = { idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [], seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]], - playproc: {}, cards: make108() + playproc: {}, playhistory: [], cards: make108() }; for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1; const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; }; @@ -141,7 +141,8 @@ function mkFollow() { get_liangpai: () => null, have_baofu: () => false, get_curmultiple: () => P.get_curmultiple(pj), - get_mustcard: s => P.get_mustcard(pj, s) + get_mustcard: s => P.get_mustcard(pj, s), + get_pushlist: () => P.get_pushlist(pj) }; P.new_playround(pj, 1, 0); return pj; diff --git a/server/games/erqiwang/test/test_input.js b/server/games/erqiwang/test/test_input.js index 0745ff1..6f7d14b 100644 --- a/server/games/erqiwang/test/test_input.js +++ b/server/games/erqiwang/test/test_input.js @@ -10,7 +10,7 @@ function newPaiju(step, banker, owner) { const paiju = { cards: make108(), step, banker, call: 65, flower: 1, seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]], - playproc: {}, callproc: [], idx: 1 + playproc: {}, playhistory: [], callproc: [], idx: 1 }; for (let i = 0; i < 108; i++) paiju.cards[i].dealowner = owner + 1; paiju.method = { diff --git a/server/games/erqiwang/test/test_paiju.js b/server/games/erqiwang/test/test_paiju.js index 5e44344..51f54c7 100644 --- a/server/games/erqiwang/test/test_paiju.js +++ b/server/games/erqiwang/test/test_paiju.js @@ -128,7 +128,7 @@ function mkBottomPaiju(maxseat, winCards, bottomScoreCards, flower) { for (let i = 0; i < 108; i++) cards[i] = { id: i, playround: -1, score: 0, dealowner: 1, playowner: -1 }; for (const [cid, sc] of bottomScoreCards) { cards[cid].playround = 0; cards[cid].score = sc; } const pc = [[], [], []]; pc[maxseat] = winCards; - const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc } }; + const o = { banker: 0, flower, cards, playproc: { maxseat, cards: pc }, playhistory: [] }; o.method = { get_burycard: () => P.get_burycard(o), get_grade_incard: c => P.get_grade_incard(o, c) }; return o; } @@ -154,7 +154,7 @@ function mkFirstPlay(flower, dealMap) { for (let i = 0; i < 108; i++) cards[i] = { id: i, dealowner: 99, playround: -1, playowner: -1, score: 0, flower: A.id_to_flower(i), number: A.id_to_number(i) }; for (const [cid, owner] of dealMap) cards[cid].dealowner = owner; const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]; - const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {} }; + const o = { step: 5, banker: 0, flower, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] }; o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) }; P.new_playround(o, 1, 0); return o; @@ -193,7 +193,7 @@ function mkTrick(deal, scores) { for (const [cid, seat] of deal) cards[cid].dealowner = seat + 1; for (const [cid, s] of scores) cards[cid].score = s; const sl = () => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]; - const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {} }; + const o = { step: 5, banker: 0, flower: 1, cards, seatlist: [sl(), sl(), sl()], playproc: {}, playhistory: [] }; o.method = { have_baofu: () => o.seatlist.some(s => s[4][0] == 0) }; P.new_playround(o, 1, 0); // 第 1 轮由庄家(0)首出 return o; diff --git a/server/games/erqiwang/test/test_rpc.js b/server/games/erqiwang/test/test_rpc.js index 8106ab6..9e90c0d 100644 --- a/server/games/erqiwang/test/test_rpc.js +++ b/server/games/erqiwang/test/test_rpc.js @@ -331,7 +331,7 @@ function mkShuai(extraToSeat1) { const pj = { idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [], seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]], - playproc: {}, cards: make108() + playproc: {}, playhistory: [], cards: make108() }; for (let i = 0; i < 108; i++) pj.cards[i].dealowner = -1; // 先全部清空 const give = (cid, s) => { pj.cards[cid].dealowner = s + 1; pj.cards[cid].playround = -1; }; @@ -372,57 +372,129 @@ t.eq('甩错 shuaicuo=1', shPk && shPk.data.shuaicuo, 1); t.eq('甩错 只打出最小一张(主♦5)', shPk && shPk.data.cards, [id(1, 1, 5)]); t.eq('甩错 无 shuai 分量', shPk && shPk.data.shuai, undefined); -// ===================== 重连 PushCards.pushlist:结构恒 3 座位 + 按本局主牌花色排序 ===================== -// 造 5 轮已出牌的牌局(每轮每家 1 张),外加 seat0 在第 1 轮同时出「主5 + 副♥A」用于验证排序 -function mkPushlist() { - const pj = { - idx: 1, step: 5, banker: 0, call: 65, flower: 1, callproc: [], - seatlist: [[[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]], [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]], - playproc: { round: 5, start: 0, currseat: 0, cards: [[], [], []] }, - cards: make108() - }; - for (let i = 0; i < 108; i++) pj.cards[i].dealowner = (i % 3) + 1; - // 每轮每家一张 - let next = 0; - for (let r = 1; r <= 5; r++) for (let s = 0; s < 3; s++) { - for (let i = next; i < 108; i++) { - if (pj.cards[i].dealowner === s + 1 && pj.cards[i].playround === -1) { pj.cards[i].playround = r; break; } +// ===================== 重连 PushCards.pushlist:与 chupai 下发的 data.cards 同一份数据 ===================== +// 【契约】「这一手打出的牌」只有一个顺序口径:do_playcard 用 order_playcards 归一一次, +// 之后 chupai1/2/3 的 data.cards、playproc.cards、playhistory→PushCards.pushlist 全都读它。 +// 因此重连包里的每一手,必须与当时那个 chupai 包的 cards【逐元素完全相等】。 +// +// 旧实现在这里分岔:pushlist 是从 cards[i].playround/dealowner 反查后自己再排一次序, +// 与 chupai 回显的「客户端提交原序」是两个写入处——一手多张(甩牌/对子)时必然不同, +// 同编码的一对牌更是排序都区分不了。故本用例必须【一手多张 + 乱序提交】才抓得住。 +// +// 【乱序提交】asc() 把选好的一手反转成升序再发,模拟真实客户端的点击顺序; +// 服务端不得回显它,一律给权威降序。 +function ascSubmit(pj, cards) { return AR.order_cards(pj.flower, cards.concat()).reverse(); } + +function drivePushlist(roomtype, hands) { + const c = driveToPlay(roomtype); + const pj = c.pj; + const played = []; // [{round, seat, cards(下发的)}...] + for (let h = 0; h < hands; h++) { + if (pj.step !== 5) break; + const seat = pj.playproc.currseat; + const round = pj.playproc.round; + const hand = P.get_seat_cards(pj, seat); + let pick = null; + if (seat === pj.playproc.start) { + // 首家优先领一对(制造"一手多张";对子两张同编码,排序无法区分,正是分岔点) + const pairs = AR.get_pairlist(pj.flower, hand.concat()); + for (const pr of pairs) { + if (AR.can_playcard(pj.flower, pr.concat(), seat, pj.seatlist, + [0, 1, 2].filter(s => s !== seat).map(s => P.get_seat_zhucards(pj, s))).result) { + pick = pr.concat(); break; + } + } + if (!pick) { pick = [hand[hand.length - 1]]; } + } else { + const n = pj.playproc.startcount; + const get = AR.get_followcard(pj.flower, hand.concat(), n, pj.playproc.startflower, pj.playproc.starttype); + const base = (get && get.mustcard) ? get.mustcard.concat() : []; + if (base.length === n) { pick = base; } + else { + const pool = (get && get.cancard && get.cancard.length ? get.cancard : hand).filter(x => base.indexOf(x) < 0); + const need = n - base.length; + const combo = []; + (function walk(st) { + if (pick) return; + if (combo.length === need) { + const cand = base.concat(combo); + if (AR.can_followcard(pj.flower, hand, cand, n, pj.playproc.startflower, pj.playproc.starttype).result + && (!pj.playproc.shuai_demand || AR.flush_follow_ok(pj.flower, hand, cand, pj.playproc.shuai_demand))) { + pick = cand; + } + return; + } + for (let i = st; i < pool.length && !pick; i++) { combo.push(pool[i]); walk(i + 1); combo.pop(); } + })(0); + } } + if (!pick) { break; } + const before = c.sent.length; + mod.chupai(pack(seat, { cards: ascSubmit(pj, pick) })); + const cp = c.sent.slice(before).filter(m => /^chupai[123]$/.test(m.rpc))[0]; + if (cp) { played.push({ round, seat, cards: cp.data.cards, submitted: ascSubmit(pj, pick) }); } } - // seat0 第1轮补出:主5(flower1) 与 副♥A(flower3),主牌必须排在副牌前 - const zhu5 = id(1, 1, 5), fuA = id(1, 3, 1); - for (const c of [zhu5, fuA]) { pj.cards[c].dealowner = 1; pj.cards[c].playround = 1; } - // 当前轮(第5轮)桌面上的牌:两种查牌模式下都必须能恢复(design §9 边界说明) - const cur = id(2, 1, 10); - pj.cards[cur].dealowner = 1; pj.cards[cur].playround = 5; - pj.playproc.cards[0] = [cur]; - pj.method = { - get_seat_cards: s => P.get_seat_cards(pj, s), - get_jian_grade: () => P.get_jian_grade(pj), - get_burycard: () => P.get_burycard(pj), - get_liangpai: () => null, - get_curmultiple: () => P.get_curmultiple(pj), - get_mustcard: s => P.get_mustcard(pj, s) - }; - return { pj, zhu5, fuA, cur }; + return { c, pj, played }; } -const pl = mkPushlist(); -const plRoom = setup("00000", pl.pj).o_room; -const di = E.get_deskinfo(plRoom, 1).PushCards.pushlist; -t.eq('重连 pushlist 轮数=5', di.length, 5); -t.eq('重连 pushlist 每轮恒3个座位', di.map(r => r.length), [3, 3, 3, 3, 3]); + +const plv = drivePushlist("00000", 12); +t.eq('pushlist 用例真的出过一手多张', plv.played.some(x => x.cards.length > 1), true); +const di = E.get_deskinfo(plv.c.o_room, 1).PushCards.pushlist; +t.eq('重连 pushlist 每轮恒3个座位', di.every(r => r.length === 3), true); t.eq('重连 pushlist 不含 undefined', di.some(r => r.some(x => x === undefined)), false); -// 第1轮 seat0 的牌:主5 必须排在副♥A 之前(按 paiju.flower=1 排序,而非泄漏的大王花色5) -t.eq('重连 pushlist 按本局主牌花色排序(主5在副A前)', - di[0][0].indexOf(pl.zhu5) < di[0][0].indexOf(pl.fuA), true); +// 核心:逐手比对「重连归档」与「当时的 chupai 包」——两条路径同一份数据,必须逐元素相等 +let plMismatch = []; +for (const h of plv.played) { + const arc = di[h.round - 1] && di[h.round - 1][h.seat]; + if (JS(arc) !== JS(h.cards)) { plMismatch.push({ round: h.round, seat: h.seat, arc, pkt: h.cards }); } +} +t.eq('重连 pushlist 每一手都与当时的 chupai.cards 逐元素相等', plMismatch, []); +// 反面:服务端不得回显客户端的提交顺序(提交是升序,下发必须是权威降序) +const plMulti = plv.played.filter(x => x.cards.length > 1); +t.eq('chupai.cards 不回显客户端提交原序(一手多张时)', + plMulti.some(x => JS(x.cards) === JS(x.submitted) && JS(x.cards) !== JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), false); +t.eq('chupai.cards 是本局主牌花色的权威降序', + plMulti.every(x => JS(x.cards) === JS(AR.order_cards(plv.pj.flower, x.cards.concat()))), true); +// 客户端提交的数组不被就地改写(can_playcard 是原地排序,do_playcard 必须先拷贝) +const inplace = driveToPlay("00000"); +const inHand = P.get_seat_cards(inplace.pj, inplace.pj.playproc.currseat); +const inArg = [inHand[inHand.length - 1], inHand[inHand.length - 2]]; +const inArgCopy = inArg.concat(); +mod.chupai(pack(inplace.pj.playproc.currseat, { cards: inArg })); +t.eq('chupai 不就地改写 pack.data.cards', inArg, inArgCopy); + // §9 不查牌模式:出牌历史属于「查牌」,一律不下发;但当前轮桌面上的牌必须照常恢复 -const pl2 = mkPushlist(); -const pc2 = E.get_deskinfo(setup("00001", pl2.pj).o_room, 1).PushCards; +const plv2 = drivePushlist("00001", 2); +const pc2 = E.get_deskinfo(plv2.c.o_room, 1).PushCards; t.eq('重连 不查牌 无出牌历史 pushlist', pc2.pushlist, undefined); -t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)', pc2.playproc.cards[0], [pl2.cur]); -const pc1 = E.get_deskinfo(plRoom, 1).PushCards; +t.eq('重连 不查牌 当前轮桌面牌仍恢复(playproc.cards)', + JS(pc2.playproc.cards[plv2.played[0].seat]), JS(plv2.played[0].cards)); +const pc1 = E.get_deskinfo(plv.c.o_room, 1).PushCards; t.eq('重连 可查牌 有出牌历史 pushlist', Array.isArray(pc1.pushlist), true); -t.eq('重连 可查牌 当前轮桌面牌也在', pc1.playproc.cards[0], [pl.cur]); + +// ===================== 重连 PushCards.baozhu:与 chupai 的 baozhu 同源同门控 ===================== +// baozhu 是「余主公示 + 明牌按钮」的开关(design §9)。它只在 chupai 包里给、重连包不给的话, +// 重连后按钮凭空消失——状态变更没有包承载(server 红线:发全下发面)。 +function mkBaozhuRoom(roomtype) { + const o_desk_stub = { paiju_list: [] }; + const pj = P.new(o_desk_stub, 0); + pj.banker = 0; pj.call = 65; pj.flower = 1; pj.step = 5; + P.new_playround(pj, 1, 0); + const lead = id(1, 3, 9); + pj.cards[lead].dealowner = 1; pj.cards[lead].playround = -1; + for (const cc of P.get_seat_zhucards(pj, 1)) { pj.cards[cc].playround = 1; } + pj.seatlist[1][4][0] = 0; // seat1 报无主 → have_baofu()=true + const c = setup(roomtype, pj); + mod.chupai(pack(0, { cards: [lead] })); + return c; +} +const bzOn = mkBaozhuRoom("00000"); +t.eq('重连 可查牌 PushCards 带 baozhu,且与 chupai 同值', + E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu, + bzOn.sent.filter(m => /^chupai[123]$/.test(m.rpc))[0].data.baozhu); +t.eq('重连 可查牌 已报无主 → baozhu=1', E.get_deskinfo(bzOn.o_room, 1).PushCards.baozhu, 1); +const bzOff = mkBaozhuRoom("00001"); +t.eq('重连 不查牌 baozhu 恒 0', E.get_deskinfo(bzOff.o_room, 1).PushCards.baozhu, 0); // ===================== §7.3 下发面 multiple 必须随「爬坡」开关取值(与结算 aset.multiple 同源)===================== // 叫 45 分:常规算子基础子数 6(design §7.1「50 及以下统一 6」),爬坡 7(design §7.3.1) @@ -506,7 +578,7 @@ t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1); // 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**, // 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。 const mkBury = (step, banker) => ({ - step, banker, cards: make108(), playproc: { currseat: banker }, + step, banker, cards: make108(), playproc: { currseat: banker }, playhistory: [], method: { check_cards_valid: c => P.check_cards_valid({ cards: make108() }, c), check_cards_inhand: () => true,