Files
erqiwang_youle/server/games/erqiwang/test/test_rpc.js
T
joywayerandClaude Opus 5 84525359fc 二七王:服务端下发面四处缺陷修复(增量路径与重连路径对齐)
前端一致性测试(同一局真包:逐包增量回放 vs 一次性 deskinfo 重连)暴露的四处
服务端缺陷,均属「同一份数据两条路径给的不一样 / 有一条根本没给」:

① chupai1/2/3 补发 playproc(本轮进行态)
   与 deskinfo.PushCards 共用新增的 get_playproc() 快照函数,结构取值完全一致。
   playproc 是全程复用的活对象(new_playround 就地重置),故快照做逐字段深拷贝,
   避免已发出的包被后续出牌回改。可见性:全部字段由桌面公开信息推出,
   与既有 PushCards.playproc 一样三家无差别下发。

② deskinfo ChooseMain(step2)/BuryCards(step3) 补发 curmultiple
   与 shangzhuang 推送同源同值;此刻一张牌未出、捡分恒 0,必为 +3(大光)。
   原先重连重建为 0,顶部「抓分」角标掉档。

③ maipai 补发 seatlist
   与 chupai/PushCards 同一张表、同一道查牌模式门控;埋牌完成时为初始化空表。

④ bottomcards / burycards 收敛到单一权威源(SSOT)
   - burycards:maipai 原样回显了客户端请求包里 cards 的顺序,改取 get_burycard()
     权威快照,与 PushCards.burycards 同一函数。请求包只承载「意图」,其顺序不可信。
   - bottomcards:get_bottomcards 按「调用时的 flower」排序,导致上庄推送(flower=-1)
     与重连 BuryCards(flower 已定) 两种顺序。底牌是选主【之前】就翻给玩家看的、
     发牌结束即固定的快照,故改为发牌时算一次并冻结,get_bottomcards 返回其副本
     (order_cards 是原地排序,交出本体会被调用方就地重排)。三个调用点无需改动。

协议文档同步 7 处(新增 playproc/seatlist/curmultiple 字段说明与两处排序口径)。
新增 test_rpc.js「下发面一致性」用例组 21 checks,含 28 轮真实牌局逐包逐座位比对
(249 次、0 不一致)与深拷贝守卫;每条均已用「退回修复必转红」反向验证。
既有 649 checks 未改一条断言,全绿(17 脚本 / 670 checks)。未涉及 shared/。

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

515 lines
33 KiB
JavaScript
Raw 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);
// ---- ④ 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: {}, 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:结构恒 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; }
}
}
// 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 };
}
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]);
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);
// §9 不查牌模式:出牌历史属于「查牌」,一律不下发;但当前轮桌面上的牌必须照常恢复
const pl2 = mkPushlist();
const pc2 = E.get_deskinfo(setup("00001", pl2.pj).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('重连 可查牌 有出牌历史 pushlist', Array.isArray(pc1.pushlist), true);
t.eq('重连 可查牌 当前轮桌面牌也在', pc1.playproc.cards[0], [pl.cur]);
// ===================== §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 },
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);
process.exit(t.done('rpc') ? 0 : 1);