// 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); process.exit(t.done('rpc') ? 0 : 1);