Files
joywayerandClaude Opus 5 b9a423071b 二七王:Balance.readystate 改为深拷贝下发,扩展深拷贝守卫测试
o_desk.prepare 是 do_prepare 就地置 1、do_new_paiju 就地重建的活数组,
整根挂进 deskinfo.Balance.readystate 会让「已发出的包」随后续准备操作被回改。
与 get_playproc / get_seatlist / get_pushlist / get_bottomcards 同一条标准,改为下发副本。

test_rpc 扩展既有的深拷贝守卫:正面(内容逐格相等)+ 非空转(活数组确实变过)
+ 反面(已发出的包不被回改、改副本不影响本体)。反向验证:去掉 .concat() 后两条断言转红。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 22:06:26 +08:00

789 lines
53 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// L3 RPC 层:§9 明牌/查牌门控、§4 投降条件。期望取自 design §4/§9
const R = require('./_rpc');
const P = require('../class.paiju.js');
const { mod, setup, make108, id } = R;
const t = require('./_assert')();
const pack = (seat, extra) => Object.assign({ data: Object.assign({ agentid: 'a', playerid: 1, gameid: 'g', roomcode: 1, seat }, extra), conmode: 0, fromid: seat });
// 被拒 = 恰好回 1 个失败包给请求者(data.success=false + errcode),不再是"静默不发包"
const ERR = mod.ERR;
const rej = (c, errcode) => c.sent.length === 1 && c.sent[0].data.success === false && c.sent[0].data.errcode === errcode;
// ===================== §9 明牌 mingpai =====================
const dealMP = [[id(1, 1, 13), 2], [id(1, 1, 12), 3]]; // seat1 主K, seat2 主Q
const mkMP = (step, baofu) => ({ step, banker: 0, flower: 1, cards: make108(dealMP), method: { have_baofu: () => baofu } });
let ctx = setup("00000", mkMP(5, true)); // 可查牌 + 报无主 + 出牌阶段
mod.mingpai(pack(0));
t.eq('明牌 合法 下发1包', ctx.sent.length, 1);
t.eq('明牌 others=他家主牌', ctx.sent[0] && ctx.sent[0].data.others, [{ seat: 1, zhucards: [id(1, 1, 13)] }, { seat: 2, zhucards: [id(1, 1, 12)] }]);
ctx = setup("00001", mkMP(5, true)); mod.mingpai(pack(0)); // 不查牌(位4=1)
t.eq('明牌 不查牌 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
ctx = setup("00000", mkMP(5, false)); mod.mingpai(pack(0)); // 未报无主
t.eq('明牌 未报无主 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
ctx = setup("00000", mkMP(3, true)); mod.mingpai(pack(0)); // 非出牌阶段
t.eq('明牌 非出牌阶段 拒(回失败包STEP)', rej(ctx, ERR.STEP), true);
// ===================== §4 投降 touxiang 条件 =====================
const mkTX = (step, banker, call) => ({ step, banker, call, method: { get_paiju_account: (type, msg) => { msg.rpc = 'jiesuan'; msg.data = { type }; return msg; } } });
let p = mkTX(2, 0, 70); ctx = setup("00000", p); mod.touxiang(pack(0)); // 70分 step2 庄家
t.eq('投降 70分step2庄家 step→6', p.step, 6);
t.eq('投降 下发jiesuan', ctx.sent.length === 1 && ctx.sent[0].rpc, 'jiesuan');
p = mkTX(2, 0, 65); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 65分 拒(回失败包RULE)', rej(ctx, ERR.RULE), true);
p = mkTX(3, 0, 70); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 埋牌后step3 拒(回失败包STEP)', rej(ctx, ERR.STEP), true);
p = mkTX(2, 1, 70); ctx = setup("00000", p); mod.touxiang(pack(0));
t.eq('投降 非庄家 拒(回失败包SEAT)', rej(ctx, ERR.SEAT), true);
// ===================== §9 出牌 chupai 的查牌门控(seatlist/baozhu)=====================
// 造真实牌局 → 强制到首出(step5),庄家(0)出一张副牌单张;seat1 已报无主
function mkChupai(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); // 本轮庄家(0)首出
const lead = id(1, 3, 9); // 副9(flower3) 作单张首出
pj.cards[lead].dealowner = 1; // 归庄家
pj.cards[lead].playround = -1;
// seat1 报无主:必须让它【真的】打空主牌。do_playcard 会按各家实际手牌重算三家的主牌统计
// (design §9:一旦有人报无主,立即为全体三人刷新),只置标志位会被重算纠正回去
for (const c of P.get_seat_zhucards(pj, 1)) pj.cards[c].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;
}
let cv = mkChupai("00000"); // 可查牌
t.eq('出牌 可查牌 有seatlist', cv.sent[0] && cv.sent[0].data.seatlist !== undefined, true);
// design §9:报无主后要为全体三人显示【另外两家】的主牌数/对子数 → 下发面必须整表带三家,
// 不能只带出牌者一家(否则另两家的统计要滞后到它们各自出牌才补上)
t.eq('出牌 可查牌 seatlist 带三家', cv.sent[0] && cv.sent[0].data.seatlist.length, 3);
t.eq('出牌 可查牌 seatlist 含非出牌者的报副统计', cv.sent[0] && cv.sent[0].data.seatlist[1][4][0], 0);
t.eq('出牌 可查牌 baozhu=1(有人报无主)', cv.sent[0] && cv.sent[0].data.baozhu, 1);
let cn = mkChupai("00001"); // 不查牌(位4=1)
t.eq('出牌 不查牌 无seatlist', cn.sent[0] && cn.sent[0].data.seatlist, undefined);
t.eq('出牌 不查牌 baozhu=0', cn.sent[0] && cn.sent[0].data.baozhu, 0);
// ===================== §4.2 mod.jiaofen 入参校验 =====================
// do_callgrade 被调用=接受;被 return 挡掉=拒绝。step 设 0 跳过后续广播
function mkJF(currcall) {
const st = { called: null };
const pj = {
step: 1, banker: -1, call: -1,
method: {
get_callgrade_seat: () => 0,
get_callgrade_value: () => currcall,
do_callgrade: c => { st.called = c; pj.step = 0; }
}
};
return { pj, st };
}
function jf(currcall, call) {
const { pj, st } = mkJF(currcall);
setup("00000", pj);
mod.jiaofen(pack(0, { call }));
return st.called;
}
t.eq('叫分 >70 拒', jf(0, 75), null);
t.eq('叫分 非5倍数 拒', jf(0, 63), null);
t.eq('叫分 首家不叫(currcall0,call0) 拒', jf(0, 0), null);
t.eq('叫分 后叫不更低(call>=currcall) 拒', jf(55, 60), null);
t.eq('叫分 首家合法65 接受', jf(0, 65), 65);
t.eq('叫分 后叫更低50 接受', jf(55, 50), 50);
t.eq('叫分 边界70 接受', jf(0, 70), 70);
// ===================== §4 70分上庄的暗牌亮牌 gating(真实 jiaofen 驱动到上庄)=====================
function driveUp(calls) {
const pj = P.new({ paiju_list: [] }, 0); // 真实牌局 step1 firstseat0
const c = setup("00000", pj);
for (const [seat, call] of calls) mod.jiaofen(pack(seat, { call }));
return c.sent.filter(m => m.rpc === 'shangzhuang');
}
// 0叫70、1不叫、2不叫 → 0上庄70
const sz70 = driveUp([[0, 70], [1, 0], [2, 0]]);
t.eq('上庄70 有shangzhuang下发', sz70.length > 0, true);
const bankerPk = sz70.find(m => m.data.cards !== undefined);
const xianPk = sz70.find(m => m.data.cards === undefined);
t.eq('上庄70 庄家包有手牌', !!bankerPk, true);
t.eq('上庄70 闲家包有暗牌', xianPk && xianPk.data.bottomcards !== undefined, true);
t.eq('上庄70 ancard3s=1', xianPk && xianPk.data.ancard3s, 1);
// 对照:叫5立即上庄(非70) → 闲家无暗牌、无ancard3s
const sz5 = driveUp([[0, 5]]);
const xian5 = sz5.find(m => m.data.cards === undefined);
t.eq('上庄5 闲家无暗牌', xian5 && xian5.data.bottomcards, undefined);
t.eq('上庄5 无ancard3s', xian5 && xian5.data.ancard3s, undefined);
// ===================== §12 端到端(叫分→上庄→选主→埋牌→出牌ready) + §9 maipai/deskinfo 门控 =====================
const E = require('../class.export.js').new();
function driveToPlay(roomtype) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 })); // 0上庄65
mod.xuanzhu(pack(0, { flower: 1 })); // 选主 方块
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(0, 8) })); // 埋任意8张
return { pj, ...c };
}
const e = driveToPlay("00000");
t.eq('端到端 叫分→选主→埋牌→step5', e.pj.step, 5);
t.eq('端到端 庄家首出(currseat=0)', e.pj.playproc.currseat, 0);
t.eq('端到端 选主flower=1', e.pj.flower, 1);
t.eq('端到端 埋牌后庄家保留28张', P.get_seat_cards(e.pj, 0).length, 28);
// §9 maipai 亮牌门控:不查牌 → 闲家包无 liangpai
const en = driveToPlay("00001");
const mpXian = en.sent.filter(m => m.rpc === 'maipai').find(m => m.data.cards === undefined);
t.eq('埋牌 不查牌 闲家无liangpai', mpXian && mpXian.data.liangpai, undefined);
// §9 deskinfo(重连) PushCards 门控:可查牌有 seatlist、不查牌无
t.eq('重连 可查牌 PushCards有seatlist', E.get_deskinfo(e.o_room, 1).PushCards.seatlist !== undefined, true);
t.eq('重连 不查牌 PushCards无seatlist', E.get_deskinfo(en.o_room, 1).PushCards.seatlist, undefined);
// ===================== 底牌 vs 埋牌底牌:字段名必须分开(S-4)=====================
// 术语:底牌 = 发牌时没发给玩家、扣在桌面的 8 张(字段 bottomcards)
// 埋牌底牌 = 庄家埋牌扣下的 8 张(字段 burycards)
// 两者曾共用 bottomcards、只靠所在包区分语义,已拆分。以下用例锁死拆分结果,防回归。
const mpAll = e.sent.filter(m => m.rpc === 'maipai');
const mpZhuang = mpAll.find(m => m.data.cards !== undefined); // 庄家包(带手牌)
const mpXian2 = mpAll.find(m => m.data.cards === undefined); // 闲家包
// 正面:庄家的 maipai 包带 burycards,且就是埋下的那 8 张
t.eq('maipai 庄家有burycards', mpZhuang && Array.isArray(mpZhuang.data.burycards), true);
t.eq('maipai burycards为8张', mpZhuang && mpZhuang.data.burycards.length, 8);
// 反面:maipai 包里不得再出现 bottomcards(改名漏改会被这条抓住)
t.eq('maipai 庄家无bottomcards', mpZhuang && mpZhuang.data.bottomcards, undefined);
// 可见性:闲家两个字段都不该有(埋牌底牌只有庄家可见)
t.eq('maipai 闲家无burycards', mpXian2 && mpXian2.data.burycards, undefined);
t.eq('maipai 闲家无bottomcards', mpXian2 && mpXian2.data.bottomcards, undefined);
// 重连 PushCards:庄家有 burycards、无 bottomcards;闲家两者皆无
const pcZhuang = E.get_deskinfo(e.o_room, 0).PushCards;
const pcXian = E.get_deskinfo(e.o_room, 1).PushCards;
t.eq('重连 PushCards 庄家有burycards', Array.isArray(pcZhuang.burycards), true);
t.eq('重连 PushCards 庄家burycards为8张', pcZhuang.burycards.length, 8);
t.eq('重连 PushCards 庄家无bottomcards', pcZhuang.bottomcards, undefined);
t.eq('重连 PushCards 闲家无burycards', pcXian.burycards, undefined);
t.eq('重连 PushCards 闲家无bottomcards', pcXian.bottomcards, undefined);
// 对照:底牌一侧字段名不受影响,仍叫 bottomcards(ChooseMain/BuryCards 为庄家专属)
const cmZhuang = E.get_deskinfo(e.o_room, 0);
t.eq('重连 ChooseMain/BuryCards 侧仍用bottomcards命名未被误改',
cmZhuang.PushCards.burycards !== undefined && cmZhuang.PushCards.bottomcards === undefined, true);
// ===================== 下发面一致性:增量路径(实时推送) 必须与 全量路径(重连 deskinfo) 给出同一份数据 =====================
// 背景:前端一致性测试(同一局真包,逐包增量回放 vs 一次性 deskinfo 重建)暴露出 4 处服务端下发面缺陷。
// 这些都不是"算错了",而是"同一份数据两条路径给的不一样 / 有一条根本没给",前端只能自己补——
// 而自己补就等于把对局推进逻辑搬到前端(前端红线「数据驱动、前端无对局状态机」)。以下逐条钉死。
const JS = x => JSON.stringify(x);
const AR = require('../class.arith.js'); // 本文件下方另有同模块的 A,此处在其声明之前,故另起名
// ---- ① bottomcards:上庄推送 / ChooseMain(step2) / BuryCards(step3) 三处必须【同序】----
// 曾经的缺陷:get_bottomcards 每次按"当时的 paiju.flower"重排,而底牌是在【选主之前】翻给庄家看的,
// 于是 step2(flower=-1) 与 step3(flower 已定) 给出两种顺序——主花色的那张牌被顶到队首。
// 现按发牌时冻结的快照下发。反面守卫:主牌花色刻意选成底牌里真实存在的花色,一旦退回"按当时 flower 排"必红。
function driveBottom(flowerPick) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
const sz = c.sent.filter(m => m.rpc === 'shangzhuang').find(m => m.data.cards !== undefined).data; // 庄家包
const step2 = E.get_deskinfo(c.o_room, 0).ChooseMain;
mod.xuanzhu(pack(0, { flower: flowerPick(pj) }));
const step3 = E.get_deskinfo(c.o_room, 0).BuryCards;
return { pj, c, sz, step2, step3 };
}
// 主牌花色必须挑一个【会真的改变底牌排序】的:否则本组用例是空转的——
// 退回"按当时 flower 重排"也照样绿(实测:随手挑底牌里第一张的花色就是空转,它本来就排在队首)。
let btReorders = false;
const bt = driveBottom(pj => {
const snap = P.get_bottomcards(pj);
for (let f = 1; f <= 4; f++) {
if (JS(AR.order_cards(f, snap.concat())) !== JS(snap)) { btReorders = true; return f; }
}
return 1;
});
// 先钉住前提:所选花色确实会让"按主牌花色排"与"冻结快照"不同序,下面三条才是有效守卫
t.eq('底牌 用例前提:所选主牌花色会改变排序(否则本组用例空转)', btReorders, true);
t.eq('底牌 上庄推送 8 张', bt.sz.bottomcards.length, 8);
t.eq('底牌 上庄推送 == 重连 ChooseMain(step2) 同序', JS(bt.step2.bottomcards), JS(bt.sz.bottomcards));
t.eq('底牌 上庄推送 == 重连 BuryCards(step3) 同序(选主后不得重排)', JS(bt.step3.bottomcards), JS(bt.sz.bottomcards));
// 快照必须是【副本】:order_cards 是原地排序,调用方就地重排不得污染冻结的快照
const bcCopy = P.get_bottomcards(bt.pj); bcCopy.reverse();
t.eq('底牌 get_bottomcards 返回副本(调用方重排不污染快照)', JS(P.get_bottomcards(bt.pj)), JS(bt.sz.bottomcards));
// ---- ② curmultiple:ChooseMain(step2) / BuryCards(step3) 必须与 shangzhuang 同源同值 ----
// 此刻一张牌未出、捡分恒为 0 → 必为 +3(大光)。曾经 deskinfo 不带此字段,重连后前端角标掉回 0。
t.eq('抓分倍数 上庄推送 curmultiple=3(捡分恒0→大光)', bt.sz.curmultiple, 3);
t.eq('抓分倍数 重连 ChooseMain.curmultiple 与上庄同值', bt.step2.curmultiple, bt.sz.curmultiple);
t.eq('抓分倍数 重连 BuryCards.curmultiple 与上庄同值', bt.step3.curmultiple, bt.sz.curmultiple);
// ---- ③ maipai.seatlist:与 PushCards.seatlist 同表同门控 ----
// 曾经的缺陷:maipai 不带 seatlist,「埋牌完成 → 庄家首出」这段窗口增量路径为空、重连路径却有表。
const mpZhuang3 = mpAll.find(m => m.data.cards !== undefined);
const mpXian3 = mpAll.find(m => m.data.cards === undefined);
t.eq('埋牌 可查牌 maipai 带 seatlist(庄闲同表)',
JS(mpZhuang3.data.seatlist), JS(E.get_deskinfo(e.o_room, 0).PushCards.seatlist));
t.eq('埋牌 可查牌 maipai 闲家同一张表(seatlist 三家公开、不裁剪)',
JS(mpXian3.data.seatlist), JS(mpZhuang3.data.seatlist));
t.eq('埋牌 seatlist 埋牌完成时为初始化空表', JS(mpZhuang3.data.seatlist),
JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]])));
// 反面:不查牌模式一律不下发(与 chupai / PushCards 同一道门控,design §9)
t.eq('埋牌 不查牌 maipai 无 seatlist',
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.seatlist === undefined), true);
// ---- ③b maipai.playproc:与 PushCards.playproc 同一个快照函数、同结构,且是深拷贝 ----
// 曾经的缺陷:mod.js 组 maipai 包时没有放 playproc(do_burycard 内部已 new_playround 就地初始化好
// round-1 的进行态),「埋牌完成 → 庄家首出」这段窗口增量路径的 table.playproc 是 null,
// 重连路径却已有初值——与 seatlist 是同一个窗口、同一条理由,被漏在了同一处。
t.eq('埋牌 maipai 带 playproc(庄闲同值)',
JS(mpZhuang3.data.playproc), JS(E.get_deskinfo(e.o_room, 0).PushCards.playproc));
t.eq('埋牌 maipai 闲家同一份 playproc(三家公开、不裁剪)',
JS(mpXian3.data.playproc), JS(mpZhuang3.data.playproc));
t.eq('埋牌 playproc 埋牌完成时为 round-1 初值(庄家首出、桌面全空)', mpZhuang3.data.playproc, {
round: 1, start: 0, currseat: 0, startcount: -1, startflower: -1, starttype: -1,
maxseat: -1, maxcard: -1, cards: [null, null, null], shuai_demand: null
});
// 反面:playproc 不受查牌门控——不像 seatlist,它只是桌面公开信息,不查牌模式下也恒有
t.eq('埋牌 不查牌 maipai 仍带 playproc',
en.sent.filter(m => m.rpc === 'maipai').every(m => m.data.playproc !== undefined), true);
// 深拷贝守卫:playproc 是全程复用的活对象,new_playround 就地重置同一个对象。
// 若下发挂的是活引用,埋牌后紧跟着打一手牌,已发出的 maipai.playproc 会被回改成下一状态的样子。
const dcPj = P.new({ paiju_list: [] }, 0);
const dcC = setup("00000", dcPj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
const dcInner = mod.app.SendPack;
let dcLive = null;
mod.app.SendPack = m => { if (m.rpc === 'maipai' && m.data.cards !== undefined) dcLive = m.data.playproc; dcInner(m); };
mod.maipai(pack(0, { cards: P.get_seat_cards(dcPj, 0).slice(0, 8) }));
mod.app.SendPack = dcInner;
const dcSnapAtSend = JS(dcLive);
const dcHand = P.get_seat_cards(dcPj, dcPj.playproc.currseat);
mod.chupai(pack(dcPj.playproc.currseat, { cards: [dcHand[dcHand.length - 1]] })); // 推进出一手,制造"若为活引用会被回改"的条件
t.eq('埋牌 playproc 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(dcLive), dcSnapAtSend);
// ---- ④ burycards:maipai 必须发服务端权威快照,不得回显客户端提交的 cards 原序 ----
// 曾经的缺陷:msg.data.burycards = cards(请求包原样回显),而 PushCards.burycards 走 get_burycard()
// 按主牌花色排序 —— 同一副埋牌底牌两条路径两种顺序(SSOT)。
const bu = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
// 刻意打乱提交顺序:手牌是从大到小的,取 8 张后【反转】成从小到大提交
const raw = P.get_seat_cards(pj, 0).slice(0, 8).reverse();
mod.maipai(pack(0, { cards: raw }));
const mp = c.sent.filter(m => m.rpc === 'maipai').find(m => m.data.burycards !== undefined);
return { pj, c, raw, mp };
})();
t.eq('埋牌底牌 maipai == 重连 PushCards 同序(SSOT)',
JS(bu.mp.data.burycards), JS(E.get_deskinfo(bu.c.o_room, 0).PushCards.burycards));
t.eq('埋牌底牌 maipai 不回显客户端提交的原序', JS(bu.mp.data.burycards) === JS(bu.raw), false);
t.eq('埋牌底牌 maipai 与提交的是同一批牌(只是顺序不同)',
JS(bu.mp.data.burycards.concat().sort((x, y) => x - y)), JS(bu.raw.concat().sort((x, y) => x - y)));
// ---- ⑤ chupai1/2/3.playproc:结构与取值必须与同一时刻的 PushCards.playproc 完全一致 ----
// 没有它,前端在非重连时拿不到「本轮谁先手/谁最大/三家各出了什么/第几轮」,只能自己累积推导。
// 用真实牌局打满 28 轮,每出一手就拿三家的下发包与三家的重连快照逐一比对。
const pp = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出活引用问题。
// 故在其之外再挂一层,原样留住服务端交出来的那个 playproc 对象。
const live = [];
const innerSend = mod.app.SendPack;
mod.app.SendPack = m => { if (/^chupai[123]$/.test(m.rpc)) live.push(m.data.playproc); innerSend(m); };
let cmp = 0, mism = 0, missing = 0, rounds = 0, guard = 0, jiesuanHasPlayproc = null, firstSnap = null;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
else for (const x of hand) if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; }
if (!pick) break;
c.sent.length = 0;
mod.chupai(pack(seat, { cards: pick }));
const cps = c.sent.filter(m => /^chupai[123]$/.test(m.rpc));
const js = c.sent.filter(m => m.rpc === 'jiesuan');
if (js.length) jiesuanHasPlayproc = js.some(m => m.data.playproc !== undefined);
if (!cps.length) continue;
rounds++;
if (cps.some(m => m.data.playproc === undefined)) missing++;
if (firstSnap === null) firstSnap = JS(live[0]); // 第 1 包发出【当时】的样子
if (pj.step !== 5) continue; // 已进结算,无"当前进行态"可比
for (let s = 0; s < 3; s++) {
const rec = E.get_deskinfo(c.o_room, s).PushCards.playproc;
cmp++;
if (JS(cps[s].data.playproc) !== JS(rec)) mism++;
}
}
mod.app.SendPack = innerSend;
return { pj, cmp, mism, missing, rounds, jiesuanHasPlayproc, firstSnap, firstRef: live[0] };
})();
t.eq('本轮进行态 28轮打完(用例真的走到底)', pp.pj.playproc.round, 28);
t.eq('本轮进行态 chupai1/2/3 恒带 playproc', [pp.rounds > 80, pp.missing], [true, 0]);
t.eq('本轮进行态 增量下发 == 同时刻重连快照(逐包逐座位)', [pp.cmp > 200, pp.mism], [true, 0]);
t.eq('本轮进行态 chupai3 转 jiesuan 时不带 playproc', pp.jiesuanHasPlayproc, false);
// 深拷贝守卫:playproc 是全程复用的活对象(new_playround 就地重置同一个对象)。
// 若下发挂的是活引用,打完整局后"第一包里的第 1 轮进行态"会变成最后一轮的样子。
t.eq('本轮进行态 下发的是深拷贝快照(已发出的包不被后续出牌回改)', JS(pp.firstRef), pp.firstSnap);
t.eq('本轮进行态 第1包就是第1轮、cards 定长3', [pp.firstRef.round, pp.firstRef.cards.length], [1, 3]);
// ===================== §5.4 甩牌:chupai1 需下发分量构成 shuai(cardtype 表达不了甩牌)=====================
// 主花色=♦(1)。庄家(0) 甩「主♦K对 + 主♦5单张」;两闲家手上一张主牌都没有 → 甩牌合法
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: {}, 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; };
give(id(1, 1, 13), 0); give(id(2, 1, 13), 0); give(id(1, 1, 5), 0); // 庄家:主♦K对 + 主♦5
give(id(1, 3, 9), 1); give(id(1, 3, 8), 1); give(id(2, 3, 9), 1); // 闲1:全副♥,无主牌
give(id(1, 2, 9), 2); give(id(1, 2, 8), 2); give(id(2, 2, 9), 2); // 闲2:全副♣,无主牌
if (extraToSeat1) give(extraToSeat1, 1); // 需要造甩错时给闲1一张大主牌
pj.method = {
check_cards_valid: c => P.check_cards_valid(pj, c),
check_cards_inhand: (c, s) => P.check_cards_inhand(pj, c, s),
do_playcard: c => P.do_playcard(pj, c),
get_seat_cards: s => P.get_seat_cards(pj, s),
have_baofu: () => (pj.seatlist[0][4][0] == 0 || pj.seatlist[1][4][0] == 0 || pj.seatlist[2][4][0] == 0),
get_curmultiple: () => P.get_curmultiple(pj),
get_mustcard: s => P.get_mustcard(pj, s)
};
P.new_playround(pj, 1, 0);
return pj;
}
// 合法甩牌 → 带 shuai 分量
let sh = mkShuai(null); let shc = setup("00000", sh);
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13), id(1, 1, 5)] }));
let shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
t.eq('甩牌 合法 下发 chupai1', !!shPk, true);
t.eq('甩牌 打出3张', shPk && shPk.data.cards.length, 3);
t.eq('甩牌 shuai=1对+1单张', shPk && shPk.data.shuai, { tractors: [], pairs: 1, singles: 1 });
t.eq('甩牌 无 shuaicuo', shPk && shPk.data.shuaicuo, undefined);
// 非甩牌(单一对子)→ 无 shuai
sh = mkShuai(null); shc = setup("00000", sh);
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13)] }));
shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
t.eq('非甩牌(单一对子) 无 shuai', shPk && shPk.data.shuai, undefined);
// 甩错(闲1 持大王,能压过主K对里的单张分量)→ 收回、只打最小一张、无 shuai
sh = mkShuai((2 - 1) * 54 + 53); shc = setup("00000", sh); // 第二副大王给闲1
mod.chupai(pack(0, { cards: [id(1, 1, 13), id(2, 1, 13), id(1, 1, 5)] }));
shPk = shc.sent.filter(m => m.rpc === 'chupai1')[0];
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:与 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) }); }
}
return { c, pj, played };
}
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);
// 核心:逐手比对「重连归档」与「当时的 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 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)',
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);
// ===================== 重连 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)
const A = require('../class.arith.js');
function driveUp45(roomtype) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
mod.jiaofen(pack(0, { call: 45 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
return { pj, c };
}
t.eq('算子 常规房 get_base_bycall(45,false)=6', A.get_base_bycall(45, false), 6);
t.eq('算子 爬坡房 get_base_bycall(45,true)=7', A.get_base_bycall(45, true), 7);
const n45 = driveUp45("00000"); // 常规算子
const p45 = driveUp45("00010"); // 位3=1 爬坡
const szm = c => c.sent.filter(m => m.rpc === 'shangzhuang')[0].data.multiple;
t.eq('上庄包 常规房 multiple=6', szm(n45.c), 6);
t.eq('上庄包 爬坡房 multiple=7', szm(p45.c), 7);
// 重连各阶段同源:选主阶段(step2) ChooseMain.multiple
t.eq('重连 常规房 ChooseMain.multiple=6', E.get_deskinfo(n45.c.o_room, 0).ChooseMain.multiple, 6);
t.eq('重连 爬坡房 ChooseMain.multiple=7', E.get_deskinfo(p45.c.o_room, 0).ChooseMain.multiple, 7);
// 与结算包 aset.multiple 一致(同一 call 同一房间规则)
const aset45 = P.get_paiju_account(
{ call: 45, banker: 0, flower: 1, cards: [], idx: 1, endtime: null, result: null, o_desk: { seatlist: [[0, [], 0, 0, 0], [0, [], 0, 0, 0], [0, [], 0, 0, 0]], o_room: { roomtype: "00010", asetcount: 6 }, method: { get_desk_account: m => m } } },
0, {}).data.aset.multiple;
t.eq('爬坡房 下发面 multiple 与结算 aset.multiple 一致', szm(p45.c), aset45);
// ===================== §11 闲家出牌提示 tishi(只转发给对家,不校验)=====================
const mkTip = (step, banker) => ({ step, banker });
// 闲家1 发"踩"(tip=1),banker=0 → 对家=2;默认 seatlist fromid==座位号
let tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, { tip: 1 }));
t.eq('提示 合法 下发1包', tp.sent.length, 1);
t.eq('提示 只发给对家(fromid=2)', tp.sent[0] && tp.sent[0].fromid, 2);
t.eq('提示 rpc=tishi', tp.sent[0] && tp.sent[0].rpc, 'tishi');
t.eq('提示 data.seat=发出者1', tp.sent[0] && tp.sent[0].data.seat, 1);
t.eq('提示 data.tip=1(踩)', tp.sent[0] && tp.sent[0].data.tip, 1);
// 闲家2 发"有分"(tip=3) → 对家=1
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(2, { tip: 3 }));
t.eq('提示 闲2发→对家1(fromid=1)', tp.sent[0] && tp.sent[0].fromid, 1);
// banker=2 时,闲家0 发"没分"(tip=2) → 对家=1
tp = setup("00000", mkTip(5, 2)); mod.tishi(pack(0, { tip: 2 }));
t.eq('提示 banker=2·闲0发→对家1', tp.sent[0] && tp.sent[0].fromid, 1);
// 庄家发 → 拒(庄家无对家)
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(0, { tip: 1 }));
t.eq('提示 庄家发 拒(回失败包RULE)', rej(tp, ERR.RULE), true);
// 非出牌阶段 → 拒
tp = setup("00000", mkTip(3, 0)); mod.tishi(pack(1, { tip: 1 }));
t.eq('提示 非出牌阶段(step3) 拒(回失败包STEP)', rej(tp, ERR.STEP), true);
// 非法 tip / 缺 tip → 拒
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, { tip: 9 }));
t.eq('提示 非法tip=9 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
tp = setup("00000", mkTip(5, 0)); mod.tishi(pack(1, {}));
t.eq('提示 缺tip 拒(回失败包PARAM)', rej(tp, ERR.PARAM), true);
// ===================== §4.2 叫分:边界与非法入参补全 =====================
// design §4.2:范围 5~70、步进 5;暂定庄家必须叫(不能"不叫"),其后才可以"叫更低"或"不叫"
t.eq('叫分 边界 下限5 接受', jf(0, 5), 5);
t.eq('叫分 正 非首家可以不叫(call=0) 接受', jf(65, 0), 0);
t.eq('叫分 反 负数 拒', jf(0, -5), null);
t.eq('叫分 反 缺 call 字段(NaN) 拒', jf(0, undefined), null);
t.eq('叫分 反 步进外的小值1 拒', jf(0, 1), null);
t.eq('叫分 反 与当前叫分相同(不更低) 拒', jf(55, 55), null);
// ===================== §4.2 已"不叫"的玩家不得再叫 =====================
// design §4.2:不叫即退出本局叫分,轮次直接跳过他;他再发 jiaofen 时已不是当前叫分位,应被拒。
const bd = P.new({ paiju_list: [] }, 0); // 真实牌局,暂定庄家 = 0 号座位
const bdc = setup("00000", bd);
mod.jiaofen(pack(0, { call: 65 }));
t.eq('叫分 正 首家叫65后轮到 seat1', bd.method.get_callgrade_seat(), 1);
mod.jiaofen(pack(1, { call: 0 })); // seat1 不叫
t.eq('叫分 正 seat1不叫后跳到 seat2', bd.method.get_callgrade_seat(), 2);
const bdLen = bd.callproc.length;
bdc.sent.length = 0;
mod.jiaofen(pack(1, { call: 60 })); // seat1 反悔想再叫
t.eq('叫分 反 已不叫者再叫 → 拒(SEAT)', rej(bdc, ERR.SEAT), true);
t.eq('叫分 反 已不叫者再叫 不改变叫分过程', bd.callproc.length, bdLen);
t.eq('叫分 反 已不叫者再叫 仍轮到 seat2', bd.method.get_callgrade_seat(), 2);
t.eq('叫分 反 已不叫者再叫 未产生庄家', bd.banker, -1);
// ===================== §4.5 埋牌 maipai:庄家 / 阶段 / 张数 =====================
// design §4.5:仅"选主/打牌"路径、由庄家从 36 张里选出 **8 张** 重新扣下
// 桩要"完整到能走完成功路径":否则一旦校验被改松,这里会**抛异常打断整个文件**,
// 后面的断言全不执行——看到的是崩溃而不是某条断言转红,定位与信号都变差。
const mkBury = (step, 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,
do_burycard: () => { },
get_seat_cards: () => [],
get_liangpai: () => null
}
});
let bc = setup("00000", mkBury(3, 1)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 非庄家 → 拒(SEAT)', rej(bc, ERR.SEAT), true);
bc = setup("00000", mkBury(2, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 选主阶段(step2)就埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
bc = setup("00000", mkBury(5, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7] }));
t.eq('埋牌 反 出牌阶段(step5)再埋 → 拒(STEP)', rej(bc, ERR.STEP), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6] }));
t.eq('埋牌 边界 只埋7张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [0, 1, 2, 3, 4, 5, 6, 7, 8] }));
t.eq('埋牌 边界 埋9张 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
bc = setup("00000", mkBury(3, 0)); mod.maipai(pack(0, { cards: [] }));
t.eq('埋牌 反 空数组 → 拒(PARAM)', rej(bc, ERR.PARAM), true);
// ===================== §11 无超时托管:对局阶段不得注册任何定时器 =====================
// design §11 已确认「倒计时只作展示,服务端不做任何超时动作」。这不是缺口,而是规则——
// 所以这里放的是**守卫用例**:若将来有人给服务端加「超时自动出牌 / AI 代打」,必然要注册
// 定时器来推进对局,这条就会转红,从而把违反 §11 的改动拦在提交前。
// (min_ontimeout 在**结算**阶段另有正当用途:延迟扣房卡与存战绩,属平台事务、不推进对局,
// 故本守卫只覆盖到出牌阶段为止。)
const realTO = global.min_ontimeout;
let timerCount = 0;
global.min_ontimeout = function () { timerCount++; };
const gd = driveToPlay("00000");
mod.chupai(pack(0, { cards: [P.get_seat_cards(gd.pj, 0)[0]] }));
global.min_ontimeout = realTO;
t.eq('§11 叫分→选主→埋牌→出牌 全程未注册任何定时器', timerCount, 0);
// ===================== I-3 阶段 step 与控制权 必须由服务端在包里显式给出 =====================
// server 红线:「阶段、控制权(轮到谁)… 必须由服务端唯一维护并在相关下发包里显式给出;
// 任何状态变更都要有包承载」。曾经的缺陷:增量推送里【一个 step 都没有】,选主/埋牌阶段
// 也没有「轮到谁」,前端只能按 rpc 名硬编码 step、并自己套「选主/埋牌 = 庄家」这条规则——
// 那就是前端持有了一小段对局状态机(服务端将来插一个中间阶段,前端不会红、只会静默画错)。
//
// 本组逐包钉死两件事:①每个包的 step == 同时刻 paiju.step(不是常量,是权威值);
// ②每个包的「轮到谁」== 服务端各 RPC 校验所用的同一个权威来源。
// 反向验证:删掉 mod.js/class.desk.js 里任意一处 msg.data.step / nextseat,本组必转红。
const sp = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
const grab = rpc => c.sent.filter(m => m.rpc === rpc);
const out = {};
mod.jiaofen(pack(0, { call: 65 }));
out.jiaofen1 = grab('jiaofen')[0]; out.after_jf1 = { step: pj.step, next: pj.method.get_callgrade_seat() };
mod.jiaofen(pack(1, { call: 0 }));
out.jiaofen2 = grab('jiaofen')[1]; out.after_jf2 = { step: pj.step, next: pj.method.get_callgrade_seat() };
out.deskinfo1 = E.get_deskinfo(c.o_room, 1);
mod.jiaofen(pack(2, { call: 0 })); // → 0 号位上庄
out.shangzhuang = grab('shangzhuang')[0]; out.after_sz = { step: pj.step, banker: pj.banker };
out.deskinfo2 = E.get_deskinfo(c.o_room, 1); // 闲家视角(控制权与视角无关)
mod.xuanzhu(pack(0, { flower: 1 }));
out.xuanzhu = grab('xuanzhu')[0]; out.after_xz = { step: pj.step, banker: pj.banker };
out.deskinfo3 = E.get_deskinfo(c.o_room, 2);
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
out.maipai = grab('maipai')[0]; out.after_mp = { step: pj.step, curr: pj.playproc.currseat };
out.deskinfo5 = E.get_deskinfo(c.o_room, 1);
return { pj, c, out };
})();
const O = sp.out;
// ① 阶段 step:每个包都有,且等于同时刻牌局的真实阶段
t.eq('阶段 jiaofen 带 step,且 == 当时的 paiju.step(仍在叫分=1)', [O.jiaofen1.data.step, O.after_jf1.step], [1, 1]);
t.eq('阶段 jiaofen(第二包) step 仍为 1', O.jiaofen2.data.step, O.after_jf2.step);
t.eq('阶段 shangzhuang 带 step,且 == paiju.step(选主/投降=2)', [O.shangzhuang.data.step, O.after_sz.step], [2, 2]);
t.eq('阶段 xuanzhu 带 step,且 == paiju.step(埋牌=3)', [O.xuanzhu.data.step, O.after_xz.step], [3, 3]);
t.eq('阶段 maipai 带 step,且 == paiju.step(出牌=5)', [O.maipai.data.step, O.after_mp.step], [5, 5]);
// 与重连包 deskinfo.step 同值:增量与全量两条路径必须给同一个阶段
t.eq('阶段 jiaofen.step == 同时刻 deskinfo.step', O.jiaofen2.data.step, O.deskinfo1.step);
t.eq('阶段 shangzhuang.step == 同时刻 deskinfo.step', O.shangzhuang.data.step, O.deskinfo2.step);
t.eq('阶段 xuanzhu.step == 同时刻 deskinfo.step', O.xuanzhu.data.step, O.deskinfo3.step);
t.eq('阶段 maipai.step == 同时刻 deskinfo.step', O.maipai.data.step, O.deskinfo5.step);
// ② 控制权:每个包的「轮到谁」== 服务端自己校验时用的权威来源
t.eq('控制权 jiaofen.nextseat == get_callgrade_seat()(下一个叫分者)', O.jiaofen1.data.nextseat, O.after_jf1.next);
t.eq('控制权 jiaofen 不叫后跳过该家', O.jiaofen2.data.nextseat, O.after_jf2.next);
t.eq('控制权 shangzhuang.nextseat == banker(选主/投降只由庄家做)', O.shangzhuang.data.nextseat, O.after_sz.banker);
t.eq('控制权 xuanzhu.nextseat == banker(埋牌只由庄家做)', O.xuanzhu.data.nextseat, O.after_xz.banker);
t.eq('控制权 maipai.seat == playproc.currseat(即将首出者)', O.maipai.data.seat, O.after_mp.curr);
// 反面:shangzhuang 的 data.seat 是【最后一个叫分者】,不是控制权——两者必须能区分开,
// 否则"直接读 seat 当控制权"的写法也能蒙混过关(本局:最后叫分者=2、庄家=0)
t.eq('控制权 shangzhuang.seat(最后叫分者) 与 nextseat(庄家) 确实不同 → 用例非空转',
O.shangzhuang.data.seat !== O.shangzhuang.data.nextseat, true);
// 重连包:ChooseMain / BuryCards 也必须显式给控制权,且与增量推送同值
t.eq('控制权 重连 ChooseMain.seat == shangzhuang.nextseat', O.deskinfo2.ChooseMain.seat, O.shangzhuang.data.nextseat);
t.eq('控制权 重连 BuryCards.seat == xuanzhu.nextseat', O.deskinfo3.BuryCards.seat, O.xuanzhu.data.nextseat);
t.eq('控制权 重连 PushCards 由 playproc.currseat 给出 == maipai.seat',
O.deskinfo5.PushCards.playproc.currseat, O.maipai.data.seat);
// ===================== I-2 结算阶段(step6)的重连包必须与 jiesuan 推送给同一份结算数据 =====================
// 曾经的缺陷:deskinfo 的 Balance 只给 readystate + aset,而 jiesuan 推送给的是
// chupai + bottom + aset(+ 末局 account)——结算面板还开着时断线重连/硬刷新,
// 抠底明细与末局大结算全部空白(同一份数据两条路径给的不一样)。
function driveToSettle(roomtype, hookDesk) {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup(roomtype, pj);
if (hookDesk) { hookDesk(c.o_desk, pj); }
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
return { pj, c, jiesuan: c.sent.filter(m => m.rpc === 'jiesuan')[0] };
}
const sv = driveToSettle("00000");
t.eq('结算 用例真的打到 step6', sv.pj.step, 6);
t.eq('结算 jiesuan 带 step=6(阶段同样由包承载)', sv.jiesuan.data.step, 6);
const bal = [0, 1, 2].map(s => E.get_deskinfo(sv.c.o_room, s).Balance);
t.eq('结算 重连 Balance.aset == jiesuan.aset(三家)',
bal.map(b => JS(b.aset)), [0, 1, 2].map(() => JS(sv.jiesuan.data.aset)));
t.eq('结算 重连 Balance.bottom == jiesuan.bottom(抠底明细,三家)',
bal.map(b => JS(b.bottom)), [0, 1, 2].map(() => JS(sv.jiesuan.data.bottom)));
// 用例非空转:抠底分组必须真的有内容(cards 恒 8 张),否则上面两边"同为 undefined"也会绿
t.eq('结算 抠底分组真的有内容(8张埋牌底牌) → 用例非空转', bal[0].bottom.cards.length, 8);
t.eq('结算 重连 deskinfo.step == 6', E.get_deskinfo(sv.c.o_room, 0).step, 6);
// 已按下准备的座位不再重发结算数据(原有门控不变)
sv.c.o_desk.prepare[1] = 1;
const balReady = E.get_deskinfo(sv.c.o_room, 1).Balance;
t.eq('结算 已准备的座位不带 aset/bottom/account(门控不变)',
[balReady.aset, balReady.bottom, balReady.account], [undefined, undefined, undefined]);
// 末局:account 也必须能从重连包拿到。用桩 get_desk_account 造出 account(真实实现要写战绩,
// 与本用例无关),验证的是「get_paiju_account 冻结了它 + get_deskinfo 原样给出」这条链路
const svLast = driveToSettle("00000", (o_desk) => {
o_desk.method.get_desk_account = m => { m.data.account = [{ score: 1 }, { score: 2 }, { score: 3 }]; return m; };
});
svLast.pj.idx = 99; // 末局判定:idx >= asetcount(6)
// idx 改在结算之前才有效,故这里重新走一遍结算路径:直接再调一次 get_paiju_account
const lastMsg = P.get_paiju_account(svLast.pj, 0, { data: {} });
t.eq('结算 末局 jiesuan 带 account', Array.isArray(lastMsg.data.account), true);
t.eq('结算 末局 重连 Balance.account == jiesuan.account',
JS(E.get_deskinfo(svLast.c.o_room, 0).Balance.account), JS(lastMsg.data.account));
// 反面:非末局不带 account(与 jiesuan 推送的取舍一致,不凭空造分组)
t.eq('结算 非末局 jiesuan 无 account', sv.jiesuan.data.account, undefined);
t.eq('结算 非末局 重连 Balance 无 account', bal[0].account, undefined);
// ===================== M-2 seatlist 必须以深拷贝下发(不把活对象挂进下发包)=====================
// paiju.seatlist 是一张全程就地改写的活表(do_playcard 每手都在改缺门/无对/报副)。
// 同一份数组曾同时挂在 maipai 的三个逐座位包、chupai1/2/3 与重连包 PushCards 上——
// 平台真实链路立刻序列化看不出来,但这是隐患,与 playproc / pushlist / bottomcards 同一条标准。
const EMPTY_TABLE = JS([0, 1, 2].map(() => [[0, 0], [0, 0], [0, 0], [0, 0], [-1, -1]]));
const sl = (() => {
const pj = P.new({ paiju_list: [] }, 0);
const c = setup("00000", pj);
mod.jiaofen(pack(0, { call: 65 })); mod.jiaofen(pack(1, { call: 0 })); mod.jiaofen(pack(2, { call: 0 }));
mod.xuanzhu(pack(0, { flower: 1 }));
// 深拷贝守卫需要【活引用】:setup 的 SendPack 会 JSON 深拷贝每个包,用 c.sent 验不出问题
const inner = mod.app.SendPack;
let mpLive = null, cpLive = null;
mod.app.SendPack = m => {
if (m.rpc === 'maipai' && mpLive === null) { mpLive = m.data.seatlist; }
if (/^chupai[123]$/.test(m.rpc) && cpLive === null) { cpLive = m.data.seatlist; }
inner(m);
};
mod.maipai(pack(0, { cards: P.get_seat_cards(pj, 0).slice(-8) }));
// 重连快照也取一份活引用(此刻还是初始空表)
const diLive = E.get_deskinfo(c.o_room, 1).PushCards.seatlist;
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) { pick = [hand[hand.length - 1]]; }
else { for (const x of hand) { if (AR.can_followcard(pj.flower, hand, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [x]; break; } } }
if (!pick) { break; }
mod.chupai(pack(seat, { cards: pick }));
}
mod.app.SendPack = inner;
return { pj, c, mpLive, cpLive, diLive };
})();
// 用例非空转:整局打完后这张活表必须真的变过,否则"没被回改"是碰巧
t.eq('座位牌况 整局打完后 paiju.seatlist 确实变过 → 用例非空转', JS(sl.pj.seatlist) !== EMPTY_TABLE, true);
t.eq('座位牌况 maipai 下发的是深拷贝(已发出的包不被后续出牌回改)', JS(sl.mpLive), EMPTY_TABLE);
t.eq('座位牌况 chupai1 下发的是深拷贝(打完整局后仍是发出当时的样子)',
JS(sl.cpLive) !== JS(sl.pj.seatlist), true);
t.eq('座位牌况 重连 PushCards 下发的是深拷贝', JS(sl.diLive), EMPTY_TABLE);
// 正面:深拷贝的内容必须与当时的活表逐格相等(拷贝不能拷错/拷浅)
const slNow = P.get_seatlist(sl.pj);
t.eq('座位牌况 get_seatlist 内容与活表逐格相等', JS(slNow), JS(sl.pj.seatlist));
slNow[0][0][0] = 9;
t.eq('座位牌况 get_seatlist 是深拷贝(改副本不影响本体)', sl.pj.seatlist[0][0][0] !== 9, true);
// ===================== Balance.readystate 必须以深拷贝下发(不把活数组挂进下发包)=====================
// o_desk.prepare 是一张全程就地改写的活数组(do_prepare 把某一格置 1、do_new_paiju 就地重建)。
// 它此前被整根挂进 deskinfo.Balance.readystate,与 playproc / seatlist / pushlist 同级的隐患:
// 已发出的包会随后续准备操作被回改。标准与 get_playproc 一致——下发副本。
const rs = driveToSettle("00000");
const rsLive = E.get_deskinfo(rs.c.o_room, 0).Balance.readystate;
t.eq('准备状态 内容与活数组逐格相等', JS(rsLive), JS(rs.c.o_desk.prepare));
t.eq('准备状态 发包时刻三家皆未准备 → 用例起点明确', JS(rsLive), JS([0, 0, 0]));
rs.c.o_desk.prepare[1] = 1; //模拟「包已发出之后,1 号位按了准备」(do_prepare 的就地写)
t.eq('准备状态 活数组确实变过 → 用例非空转', JS(rs.c.o_desk.prepare), JS([0, 1, 0]));
t.eq('准备状态 下发的是深拷贝(已发出的包不被后续准备操作回改)', JS(rsLive), JS([0, 0, 0]));
// 反面:改副本不得影响本体
rsLive[2] = 9;
t.eq('准备状态 是深拷贝(改副本不影响本体)', rs.c.o_desk.prepare[2], 0);
process.exit(t.done('rpc') ? 0 : 1);