二七王:出牌顺序收敛为唯一口径,重连包补发 baozhu
同一份数据「这一手打出的牌」此前有两个顺序口径:chupai*/playproc.cards 回显客户端请求原序,重连包 pushlist 则从 playround 反查后自己再排一次序。 一手多张(甩牌/对子/拖拉机)时两者必然分岔;同编码的一对牌更是排序都 区分不了,靠"两处各自排序"永远收敛不了。 改为单一写入处: - 新增 order_playcards(),do_playcard 入口归一化一次(顺带隔离 can_playcard 的原地排序,不再就地改写 pack.data.cards) - 新增 paiju.playhistory,出牌当场归档;新增 get_pushlist() 只做深拷贝 - class.export.js 删掉整段反查+重排,改为直读归档 - do_playcard 三个分支都给 re.cards,mod.js 去掉回落到入参的兜底 顺带修复重连包漏发 baozhu:它是余主公示与明牌按钮的开关,此前只在 chupai 包里给,报无主后重连按钮会凭空消失。现与 chupai 同源同门控下发。 协议文档新增 §0.6「一手出牌」的顺序口径,并补 PushCards.baozhu 字段。 测试:pushlist 一节改为真实链路驱动(一手多张 + 乱序提交),断言升级为 「重连每一手与当时的 chupai.cards 逐元素相等」;三处修复均已退回验证转红。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: //结算
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 == 请求者座位` 时才有**,且只算请求者自己的手牌;未轮到本家、本轮首家、甩牌局面、必出牌为空时均无此属性 |
|
||||
|
||||
|
||||
@@ -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 要求「一旦有玩家报无主,就为全体三人
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
// 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 };
|
||||
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) }); }
|
||||
}
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user