diff --git a/server/games/erqiwang/class.arith.js b/server/games/erqiwang/class.arith.js index 891676a..ac5a4ca 100644 --- a/server/games/erqiwang/class.arith.js +++ b/server/games/erqiwang/class.arith.js @@ -1043,6 +1043,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { _inhandcards = cls_youle_erqiwang_arith.order_cards(mainflower, _inhandcards); _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); @@ -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); if (_code > 1000){ _flower = mainflower; @@ -1123,8 +1131,8 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { can.noflower = 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); if (_code > 1000){ _flower = mainflower; @@ -1135,7 +1143,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { } //如果第一家出的是对子或拖拉机牌型,而跟牌没有出对子则认为没有相同花色的对子 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){ can.nopair = true; } @@ -1144,7 +1152,7 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { //根据第一家的出牌计算牌值大小 //一张单张 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); if (_code > 1000){ //大于1000是主牌 _flower = mainflower; @@ -1173,13 +1181,13 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { //一对 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){ can.cardvalue = 0; 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); if (_code > 1000){ _flower = mainflower; @@ -1207,14 +1215,14 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || { //拖拉机 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); if (tuolaji_list.length != 1){ can.cardvalue = 0; 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); if (_code > 1000){ _flower = mainflower; diff --git a/server/games/erqiwang/class.export.js b/server/games/erqiwang/class.export.js index 1b8d46e..3cb7194 100644 --- a/server/games/erqiwang/class.export.js +++ b/server/games/erqiwang/class.export.js @@ -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){ + if (!o_room.o_desk){ + return null; + } + var paiju = o_room.o_desk.method.curr_paiju(); + if (!paiju){ + return null; + } 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; } diff --git a/server/games/erqiwang/test/test_endgame.js b/server/games/erqiwang/test/test_endgame.js index 03d1e6a..fcc8b7e 100644 --- a/server/games/erqiwang/test/test_endgame.js +++ b/server/games/erqiwang/test/test_endgame.js @@ -89,7 +89,40 @@ t.eq('§4.6 投降 result=2(下家坐庄)', tx.pj.result, 2); // ============ §12.2 中途解散 ============ const ds = seatBanker("00000", 65); 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]); +// 平台在 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); diff --git a/server/games/erqiwang/test/test_follow.js b/server/games/erqiwang/test/test_follow.js index 27d2698..e366d80 100644 --- a/server/games/erqiwang/test/test_follow.js +++ b/server/games/erqiwang/test/test_follow.js @@ -104,4 +104,36 @@ t.eq('混 拖·2副散+补2主 不压', beat(f([N9(1), N8(1), zK(1), zQ(1), zJ(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); +// ===================== 入参顺序无关性(服务端不得依赖客户端提交的数组顺序)===================== +// 回归: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);