二七王:出牌顺序收敛为唯一口径,重连包补发 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:
2026-08-27 20:12:03 +08:00
co-authored by Claude Opus 5
parent 5689ceab6f
commit 4508fe005f
8 changed files with 233 additions and 89 deletions
+65 -2
View File
@@ -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;