二七王:修复跟牌判定依赖客户端入参顺序(可翻转本轮胜负与结算)

can_followcard 尾段计算 cardvalue/noflower/nopair 时用的是入参原始数组
followcards(客户端提交顺序),而 get_pairlist/get_tuolaji_list 都按降序
相邻取对。端到端实测:闲家用主拖拉机毙牌且为末轮,降序提交时闲家赢下本轮
(捡分 40、扣底 ×4),把同一手牌打乱成 [51,50,105,104] 后对子漏判、
cardvalue 归 0,变成庄家赢、闲家 0 分大光、不扣底——同一手合法牌仅靠数组
顺序就能翻转胜负、捡分归属与最终结算,同源问题还能抹掉 noflower 缺门标志
污染 §9 下发给全场的牌况表。

改为在 min_ary_deduct 削减 _followcards 之前另存完整排序快照 _sortfollow,
尾段 8 处全部改用它(_followcards 会被削减、不可复用)。

顺带给 get_disbandRoom 加空守卫:平台在 makewar 后立刻置 battlestate=1,
而首局是延迟 1 秒创建的,这段窗口内解散会在 curr_paiju() 的 undefined 上
解引用抛异常、打断平台解散链路;现返回 null 走平台既有的「不带 deskfree」分支。

补 8 条回归(入参顺序无关性 5 条 + 端到端 1 条 + 解散空守卫 2 条),
已用「回退修复 → 用例转红」反验有效性;全套单测 401 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 12:18:04 +08:00
co-authored by Claude Opus 5
parent 685b629854
commit ce4c972b7b
4 changed files with 97 additions and 13 deletions
+18 -10
View File
@@ -1043,6 +1043,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
_inhandcards = cls_youle_erqiwang_arith.order_cards(mainflower, _inhandcards); _inhandcards = cls_youle_erqiwang_arith.order_cards(mainflower, _inhandcards);
_followcards = cls_youle_erqiwang_arith.order_cards(mainflower, _followcards); _followcards = cls_youle_erqiwang_arith.order_cards(mainflower, _followcards);
//已排序跟牌的完整快照:下面的必出/可出校验会用 min_ary_deduct 削减 _followcards,
//而后半段的牌面值(cardvalue)与缺门/无对(noflower/nopair)判定必须基于「完整且已排序」的跟牌。
//这里绝不能改用入参 followcards——那是客户端提交的原始顺序,而 get_pairlist / get_tuolaji_list
//都按降序相邻取对:乱序提交会漏判对子/拖拉机,把本该压过的主拖拉机算成牌面 0(本轮胜者、
//捡分归属、扣底倍数全被客户端的数组顺序左右),也能抹掉缺门标志污染 §9 牌况表。
//(dev-guide server 04 §8:前端不是数据源,服务端不得依赖客户端可控的入参顺序)
var _sortfollow = _followcards.concat();
//根据第一个玩家的出牌获取后面玩家必出的和可出的牌 //根据第一个玩家的出牌获取后面玩家必出的和可出的牌
var get = cls_youle_erqiwang_arith.get_followcard(mainflower, _inhandcards, startcount, startflower, startcardtype); var get = cls_youle_erqiwang_arith.get_followcard(mainflower, _inhandcards, startcount, startflower, startcardtype);
@@ -1113,8 +1121,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
/////////// 可以跟牌 /////////// /////////// 可以跟牌 ///////////
//根据跟牌情况获取是否有相同花色的牌,是否有对子 //根据跟牌情况获取是否有相同花色的牌,是否有对子
//如果第一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌 //如果最大的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]); var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100); var _flower = parseInt(_code / 100);
if (_code > 1000){ if (_code > 1000){
_flower = mainflower; _flower = mainflower;
@@ -1123,8 +1131,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
can.noflower = true; can.noflower = true;
can.nopair = true; can.nopair = true;
} }
//如果最后一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌 //如果最小的一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[followcards.length - 1]); var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[_sortfollow.length - 1]);
var _flower = parseInt(_code / 100); var _flower = parseInt(_code / 100);
if (_code > 1000){ if (_code > 1000){
_flower = mainflower; _flower = mainflower;
@@ -1135,7 +1143,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
} }
//如果第一家出的是对子或拖拉机牌型,而跟牌没有出对子则认为没有相同花色的对子 //如果第一家出的是对子或拖拉机牌型,而跟牌没有出对子则认为没有相同花色的对子
if (startcardtype > 200){ if (startcardtype > 200){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards); var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
if (pairlist.length != startcardtype % 100){ if (pairlist.length != startcardtype % 100){
can.nopair = true; can.nopair = true;
} }
@@ -1144,7 +1152,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//根据第一家的出牌计算牌值大小 //根据第一家的出牌计算牌值大小
//一张单张 //一张单张
if (startcardtype == 101){ if (startcardtype == 101){
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]); var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100); var _flower = parseInt(_code / 100);
if (_code > 1000){ //大于1000是主牌 if (_code > 1000){ //大于1000是主牌
_flower = mainflower; _flower = mainflower;
@@ -1173,13 +1181,13 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//一对 //一对
if (startcardtype == 201){ if (startcardtype == 201){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards); var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
if (pairlist.length != 1){ if (pairlist.length != 1){
can.cardvalue = 0; can.cardvalue = 0;
return can; return can;
} }
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]); var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100); var _flower = parseInt(_code / 100);
if (_code > 1000){ if (_code > 1000){
_flower = mainflower; _flower = mainflower;
@@ -1207,14 +1215,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
//拖拉机 //拖拉机
if (startcardtype > 300 && startcardtype < 400){ if (startcardtype > 300 && startcardtype < 400){
var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, followcards); var pairlist = cls_youle_erqiwang_arith.get_pairlist(mainflower, _sortfollow);
var tuolaji_list = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, pairlist, startcardtype); var tuolaji_list = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, pairlist, startcardtype);
if (tuolaji_list.length != 1){ if (tuolaji_list.length != 1){
can.cardvalue = 0; can.cardvalue = 0;
return can; return can;
} }
var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, followcards[0]); var _code = cls_youle_erqiwang_arith.id_to_code(mainflower, _sortfollow[0]);
var _flower = parseInt(_code / 100); var _flower = parseInt(_code / 100);
if (_code > 1000){ if (_code > 1000){
_flower = mainflower; _flower = mainflower;
+13 -2
View File
@@ -217,10 +217,21 @@ var cls_youle_erqiwang_export = cls_youle_erqiwang_export || {
} }
//解散房间 //解散房间
//平台在 battlestate==1 时即会调用(rpc.js 开战处 makewar 后立刻置 1),而本游戏的首个牌局
//是 makewar 里 min_ontimeout(...,1000) 延迟创建的——这段窗口内 o_desk 已存在但 paiju_list 为空。
//此时返回 null(平台对 null 的 _deskfree 有分支:照常发解散包、不带 deskfree),
//而不是让 curr_paiju() 的 undefined 解引用抛异常打断整条解散链路。
exp.get_disbandRoom = function(o_room){ exp.get_disbandRoom = function(o_room){
if (!o_room.o_desk){
return null;
}
var paiju = o_room.o_desk.method.curr_paiju();
if (!paiju){
return null;
}
var msg = {}; var msg = {};
return o_room.o_desk.method.curr_paiju().method.get_paiju_account(2, msg); return paiju.method.get_paiju_account(2, msg);
} }
return exp; return exp;
} }
+34 -1
View File
@@ -89,7 +89,40 @@ t.eq('§4.6 投降 result=2(下家坐庄)', tx.pj.result, 2);
// ============ §12.2 中途解散 ============ // ============ §12.2 中途解散 ============
const ds = seatBanker("00000", 65); const ds = seatBanker("00000", 65);
mod.xuanzhu(pk(0, { flower: 1 })); mod.xuanzhu(pk(0, { flower: 1 }));
const dsmsg = require('../class.export.js').new().get_disbandRoom(ds.ctx.o_room); const EXPORT = require('../class.export.js').new();
const dsmsg = EXPORT.get_disbandRoom(ds.ctx.o_room);
t.eq('§12.2 解散:不结算子数、result=3', [dsmsg.data.aset.multiple, dsmsg.data.aset.seatlist.map(p => p.grade), ds.pj.result], [0, [0, 0, 0], 3]); t.eq('§12.2 解散:不结算子数、result=3', [dsmsg.data.aset.multiple, dsmsg.data.aset.seatlist.map(p => p.grade), ds.pj.result], [0, [0, 0, 0], 3]);
// 平台在 makewar 后立刻置 battlestate=1,而首局是延迟 1 秒创建的:这段窗口内解散必须返回 null,
// 而不是让 curr_paiju() 的 undefined 解引用抛异常打断平台的整条解散链路
t.eq('解散·牌局尚未创建 → 返回 null 不抛异常',
EXPORT.get_disbandRoom({ o_desk: { method: { curr_paiju: () => undefined } } }), null);
t.eq('解散·牌桌尚未创建 → 返回 null 不抛异常', EXPORT.get_disbandRoom({}), null);
// ============ 出牌入参顺序无关性(端到端)============
// 同一手完全合法的跟牌,仅客户端提交的数组顺序不同,本轮胜者/捡分/扣底/结算判定必须完全一致。
// 构造:庄家(0)首出 红心KKQQ(副牌两连对,含20分),闲家(1)用 黑桃KKQQ(主拖拉机,含20分)毙牌,
// 闲家(2)垫方块杂牌;这是本局最后一轮,故同时覆盖扣底判定。
function oneTrick(followOrder) {
const p = P.new({ paiju_list: [] }, 0);
const c = setup("00000", p);
p.banker = 0; p.call = 65; p.flower = 4; p.step = 5;
// 先把所有牌置为「第1轮已由庄家赢走」,再把参战的 12 张发回三家手上
for (const card of p.cards) { card.playround = 1; card.playowner = 0; }
const give = (ids, owner) => ids.forEach(i => Object.assign(p.cards[i], { dealowner: owner + 1, playround: -1, playowner: -1 }));
give([38, 92, 37, 91], 0); // 红心 K K Q Q
give([51, 105, 50, 104], 1); // 黑桃 K K Q Q(主)
give([5, 7, 8, 10], 2); // 方块杂牌
P.new_playround(p, 1, 0);
mod.chupai(pk(0, { cards: [38, 92, 37, 91] }));
mod.chupai(pk(1, { cards: followOrder }));
mod.chupai(pk(2, { cards: [5, 7, 8, 10] }));
const m = c.ctx ? c.ctx : c;
const j = m.sent[m.sent.length - 1];
return [p.playproc.maxseat, j.data.chupai.grade, j.data.aset.grade, j.data.bottom.multiple];
}
const ordered = oneTrick([51, 105, 50, 104]); // 降序
const shuffled = oneTrick([51, 50, 105, 104]); // 乱序(把两个对子拆开摆)
t.eq('序无关 端到端·有序提交:闲家毙牌赢下末轮并扣底×4', ordered, [1, 40, 40, 4]);
t.eq('序无关 端到端·乱序提交结果完全一致', shuffled, ordered);
process.exit(t.done('endgame') ? 0 : 1); process.exit(t.done('endgame') ? 0 : 1);
+32
View File
@@ -104,4 +104,36 @@ t.eq('混 拖·2副散+补2主 不压', beat(f([N9(1), N8(1), zK(1), zQ(1), zJ(1
// 首家副拖拉机:手中1对该副花色(数量仍不足) + 主牌 → 有对也压不过(混合非纯主拖) // 首家副拖拉机:手中1对该副花色(数量仍不足) + 主牌 → 有对也压不过(混合非纯主拖)
t.eq('混 拖·副对(不足)+补2主 不压', beat(f([N9(1), N9(2), zK(1), zQ(1), zJ(1)], [N9(1), N9(2), zK(1), zQ(1)], 4, 2, 302)), false); t.eq('混 拖·副对(不足)+补2主 不压', beat(f([N9(1), N9(2), zK(1), zQ(1), zJ(1)], [N9(1), N9(2), zK(1), zQ(1)], 4, 2, 302)), false);
// ===================== 入参顺序无关性(服务端不得依赖客户端提交的数组顺序)=====================
// 回归:can_followcard 尾段曾用入参原始顺序的 followcards 算 cardvalue/noflower/nopair,
// 而 get_pairlist/get_tuolaji_list 按降序相邻取对——客户端只要打乱数组顺序,
// 就能把本该压过的主拖拉机算成牌面 0(改写本轮胜者/捡分/扣底),或抹掉缺门标志污染 §9 牌况表。
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// 主拖拉机毙牌:有序 vs 乱序,结果必须完全一致且都判为「压过」
const HT = [zK(1), zK(2), zQ(1), zQ(2), oth(9)];
const ht1 = f(HT, [zK(1), zK(2), zQ(1), zQ(2)], 4, 2, 302); // 降序
const ht2 = f(HT, [zK(1), zQ(1), zK(2), zQ(2)], 4, 2, 302); // 乱序(对子被拆开摆放)
t.eq('序无关 主拖毙牌·有序压过', beat(ht1), true);
t.eq('序无关 主拖毙牌·乱序同样压过', beat(ht2), true);
t.eq('序无关 主拖毙牌·两种顺序结果全等', same(ht1, ht2), true);
// 主对子毙牌
const HP = [zK(1), zK(2), oth(9)];
t.eq('序无关 主对毙牌·两种顺序结果全等',
same(f(HP, [zK(1), zK(2)], 2, 2, 201), f(HP, [zK(2), zK(1)], 2, 2, 201)), true);
// 同花色拖拉机跟牌(非毙牌)的牌面值也不得随顺序变
const HS = [J(1), J(2), T(1), T(2), N8(1)];
t.eq('序无关 同花色跟拖·两种顺序结果全等',
same(f(HS, [J(1), J(2), T(1), T(2)], 4, 2, 302), f(HS, [J(1), T(1), J(2), T(2)], 4, 2, 302)), true);
// 混合出牌:3 张该副花色 + 1 主牌。把副牌摆在首尾也必须记为「已缺该花色」
const HM = [K(1), Q(1), J(1), zK(1)];
const hm1 = f(HM, [zK(1), K(1), Q(1), J(1)], 4, 2, 302); // 降序:主牌在首
const hm2 = f(HM, [K(1), zK(1), Q(1), J(1)], 4, 2, 302); // 乱序:首尾都是副牌
t.eq('序无关 混合出牌·有序记缺门', hm1.noflower, true);
t.eq('序无关 混合出牌·乱序同样记缺门', hm2.noflower, true);
t.eq('序无关 混合出牌·两种顺序结果全等', same(hm1, hm2), true);
process.exit(t.done('follow') ? 0 : 1); process.exit(t.done('follow') ? 0 : 1);