// 夹具自检:真包必须覆盖全部阶段,且保留座位差异 const fs = require('fs'); const path = require('path'); const { ROOT } = require('./_load'); const t = require('./_assert')(); const fx = JSON.parse(fs.readFileSync(path.join(ROOT, 'client/tests/fixtures/packets.json'), 'utf8')); t.eq('三个座位', fx.seats.length, 3); const rpcsOf = seat => fx.seats[seat].map(p => p.rpc); const allRpcs = [].concat(rpcsOf(0), rpcsOf(1), rpcsOf(2)); ['fapai','jiaofen','shangzhuang','xuanzhu','maipai','chupai1','chupai2','chupai3','jiesuan'] .forEach(rpc => t.eq('夹具覆盖 ' + rpc, allRpcs.indexOf(rpc) >= 0, true)); // 差异化下发确实存在:bottomcards 恰好只发给庄家一人(不是"少于三家"这种可被空集合蒙混过去的弱断言) const withBottom = [0,1,2].filter(s => fx.seats[s].some(p => p.rpc === 'shangzhuang' && p.data.hasOwnProperty('bottomcards'))); t.eq('底牌恰好发给1个座位(庄家)', withBottom.length, 1); // deskinfo 快照必须覆盖全部 5 个阶段(下游任务要用 step3 埋牌快照做回归断言) const steps = fx.deskinfo.map(d => d.step).filter((v, i, a) => a.indexOf(v) === i).sort((a,b) => a - b); t.eq('deskinfo 覆盖全部阶段 1/2/3/5/6', steps, [1, 2, 3, 5, 6]); // roomtype 必须存在且非空,Task 12 要用它还原房间选项 t.eq('roomtype 存在且非空', typeof fx.roomtype === 'string' && fx.roomtype.length > 0, true); // startwar 是增量路径的真实起点(开局握手,结构=deskwar=deskinfo),必须每座位各一份, // 且不得混进 seats[] 数组头部——那会让全部 packetIndex 集体错位一位(见夹具补捕说明) t.eq('startwar 存在且为 3 份', Array.isArray(fx.startwar) && fx.startwar.length, 3); [0, 1, 2].forEach(s => { const dw = fx.startwar[s] && fx.startwar[s].deskwar; t.eq('startwar[' + s + '].deskwar 存在', !!dw, true); t.eq('startwar[' + s + '] 结构=deskinfo(带 step/PlayerInfo/MyCards)', dw && dw.hasOwnProperty('step') && dw.hasOwnProperty('PlayerInfo') && dw.hasOwnProperty('MyCards'), true); t.eq('startwar[' + s + '].PlayerInfo 为开局初值(三家均 0)', dw && dw.PlayerInfo, [0, 0, 0]); }); // seats[] 的第一包必须仍是 fapai——确认 startwar 没有被误插进 seats[] 头部 [0, 1, 2].forEach(s => { t.eq('seats[' + s + '][0] 仍是 fapai(startwar 未混入 seats)', fx.seats[s][0].rpc, 'fapai'); }); // packetIndex 必须落在 (0, 该座位包总数] 区间内,且与 info.step 自洽 fx.deskinfo.forEach(d => { const inRange = d.packetIndex > 0 && d.packetIndex <= fx.seats[d.seat].length; t.eq('packetIndex 落在有效区间 (step' + d.step + ' seat' + d.seat + ')', inRange, true); t.eq('deskinfo.step 与 info.step 一致 (seat' + d.seat + ' pi' + d.packetIndex + ')', d.step, d.info.step); }); // C-1 回归锁:同一座位的两条 step5 快照,playproc(当前轮桌面牌/currseat/round)不得相同—— // 否则说明 deskinfo 快照存的是活引用,被后续出牌篡改成了终局状态(曾经的真实缺陷) const step5BySeat = {}; fx.deskinfo.filter(d => d.step === 5).forEach(d => { (step5BySeat[d.seat] = step5BySeat[d.seat] || []).push(d); }); Object.keys(step5BySeat).forEach(seat => { const list = step5BySeat[seat]; if (list.length < 2) { return; } for (let i = 1; i < list.length; i++) { const same = JSON.stringify(list[0].info.PushCards.playproc) === JSON.stringify(list[i].info.PushCards.playproc); t.eq('seat' + seat + ' 两条 step5 快照 playproc 不相同(未被活引用污染)', same, false); } }); // I-3:推进阶段的下发包必须自带 step,且与该时刻的 deskinfo.step 属于同一套取值。 // 前端不得按 rpc 名硬编码阶段(前端红线「数据驱动、前端无对局状态机」)。 const STEP_OF = { fapai: 1, jiaofen: 1, shangzhuang: 2, xuanzhu: 3, maipai: 5, jiesuan: 6 }; const noStep = [], badStep = []; [0, 1, 2].forEach(s => fx.seats[s].forEach(p => { if (!STEP_OF.hasOwnProperty(p.rpc)) { return; } if (!p.data.hasOwnProperty('step')) { noStep.push(s + ':' + p.rpc); return; } if (p.data.step !== STEP_OF[p.rpc]) { badStep.push(s + ':' + p.rpc + '=' + p.data.step); } })); t.eq('推进阶段的下发包都带 step', noStep, []); t.eq('各包的 step 就是该时点的真实阶段', badStep, []); // I-3:选主/埋牌阶段的「轮到谁」必须由服务端显式给出(增量 nextseat / 重连分组的 seat) const noNext = []; [0, 1, 2].forEach(s => fx.seats[s].forEach(p => { if ((p.rpc === 'shangzhuang' || p.rpc === 'xuanzhu') && !p.data.hasOwnProperty('nextseat')) { noNext.push(s + ':' + p.rpc); } })); t.eq('shangzhuang/xuanzhu 都带 nextseat(控制权)', noNext, []); fx.deskinfo.filter(d => d.step === 2).forEach(d => { t.eq('重连 ChooseMain 带控制权 seat (seat' + d.seat + ')', typeof d.info.ChooseMain.seat, 'number'); }); fx.deskinfo.filter(d => d.step === 3).forEach(d => { t.eq('重连 BuryCards 带控制权 seat (seat' + d.seat + ')', typeof d.info.BuryCards.seat, 'number'); }); // I-2:结算阶段的重连包必须与 jiesuan 推送给同一份结算数据(抠底明细 bottom 恒有; // account 只在末局,本夹具是第 1/6 局,故两条路径都没有) const jiesuanPk = fx.seats[0].filter(p => p.rpc === 'jiesuan')[0]; t.eq('夹具的 jiesuan 带抠底明细 bottom', !!(jiesuanPk && jiesuanPk.data.bottom), true); fx.deskinfo.filter(d => d.step === 6).forEach(d => { t.eq('重连 Balance.bottom == jiesuan.bottom (seat' + d.seat + ')', JSON.stringify(d.info.Balance.bottom), JSON.stringify(jiesuanPk.data.bottom)); t.eq('非末局:两条路径都不带 account (seat' + d.seat + ')', [d.info.Balance.hasOwnProperty('account'), jiesuanPk.data.hasOwnProperty('account')], [false, false]); }); // 每个包都有 success(协议 §0.1:每个下发包的 data 必带 success) const noSuccess = []; [0,1,2].forEach(s => fx.seats[s].forEach(p => { if (!p.data || !p.data.hasOwnProperty('success')) { noSuccess.push(s + ':' + p.rpc); } })); t.eq('每个下发包都带 success', noSuccess, []); process.exit(t.done('fixture') ? 0 : 1);