// 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); // ===================== §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) }; 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 }; 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, []]], 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);