同一份数据「这一手打出的牌」此前有两个顺序口径: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>
618 lines
40 KiB
JavaScript
618 lines
40 KiB
JavaScript
// 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);
|
||
|
||
process.exit(t.done('rpc') ? 0 : 1);
|