// 流程完整性(design §4.2 / §4.6 / §12.1 阶段机) // // ① **阶段机迁移矩阵**:8 个 RPC × 5 个阶段 = 40 格,逐格验证「该受理的受理、不该受理的 // 一律回 STEP 失败包」。此前每个 handler 只测了 1~2 条代表性失败路径,没有系统覆盖 // 「某个请求在别的阶段会不会被误放行 / 会不会静默丢弃」。 // ② **叫分起始者**:第一局默认座位 0;之后每局起始叫分者 = 暂定庄家(庄赢连庄 / 庄输下家); // 每一局的首家都不能"不叫"。连庄分支单独构造(叫 70 分 + 首家出最大牌)才走得到。 // // 随机源与发牌都接固定种子,结果可复现。 const R = require('./_rpc.js'); const mod = R.mod; const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith; const D = require('../class.desk.js'); const t = require('./_assert')(); let seed = 0x5A5A01; const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; }; global.min_ontimeout = fn => fn(); global.min_random = (a, b) => a + rnd(b - a + 1); const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) }); const ERR = mod.ERR; function mkRoom(rt, asetcount) { const sent = []; const o_room = { roomtype: rt, asetcount: asetcount || 6, roomcode: 1, createtime: 'T', makewartime: 'T', seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })), method: { sendpack_toother: m => sent.push(JSON.parse(JSON.stringify(m))) } }; const desk = D.new(o_room); o_room.o_desk = desk; global.youle_erqiwang.app = { SendPack: m => sent.push(JSON.parse(JSON.stringify(m))) }; global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } }; mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app; return { o_room, desk, sent }; } // 把牌局驱动到指定阶段。call=70 时保留投降资格 function driveTo(step, call) { const c = mkRoom('00000'); D.do_new_paiju(c.desk, 0); const pj = c.desk.method.curr_paiju(); if (step >= 2) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: call || 65 })); if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); if (pj.step === 1) mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); } if (step >= 3) mod.xuanzhu(pk(pj.banker, { flower: 1 })); if (step >= 5) mod.maipai(pk(pj.banker, { cards: P.get_seat_cards(pj, pj.banker).slice(-8) })); if (step >= 6) { let g = 0; while (pj.step === 5 && ++g < 400) { const s = pj.playproc.currseat, h = P.get_seat_cards(pj, s); let p = null; if (s === pj.playproc.start) p = [h[h.length - 1]]; else for (const x of h) if (A.can_followcard(pj.flower, h, [x], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { p = [x]; break; } if (!p) break; mod.chupai(pk(s, { cards: p })); } } return { c, pj }; } // 各 RPC 在「本阶段的正确发起者 + 正确入参」下发一次;返回是否被受理 const FIRE = { jiaofen: (c, pj) => { const s = pj.step === 1 ? pj.method.get_callgrade_seat() : 0; mod.jiaofen(pk(s, { call: 65 })); }, xuanzhu: (c, pj) => mod.xuanzhu(pk(pj.banker >= 0 ? pj.banker : 0, { flower: 1 })), touxiang: (c, pj) => mod.touxiang(pk(pj.banker >= 0 ? pj.banker : 0)), maipai: (c, pj) => { const b = pj.banker >= 0 ? pj.banker : 0; const h = P.get_seat_cards(pj, b); mod.maipai(pk(b, { cards: h.slice(-8) })); }, chupai: (c, pj) => { const s = (pj.playproc && pj.playproc.currseat >= 0) ? pj.playproc.currseat : (pj.banker >= 0 ? pj.banker : 0); const h = P.get_seat_cards(pj, s); mod.chupai(pk(s, { cards: h.length ? [h[h.length - 1]] : [0] })); }, mingpai: (c, pj) => { // design §9:明牌按钮在【有人报无主之后】才出现,故 step5 还要先满足这个前置 if (pj.step === 5) { const s2 = (pj.banker + 1) % 3; for (const cd of P.get_seat_zhucards(pj, s2)) pj.cards[cd].playround = 1; pj.seatlist[s2][4][0] = 0; } mod.mingpai(pk(pj.banker >= 0 ? (pj.banker + 1) % 3 : 1)); }, tishi: (c, pj) => mod.tishi(pk(pj.banker >= 0 ? (pj.banker + 1) % 3 : 1, { tip: 1 })), zhunbei: (c, pj) => mod.zhunbei(pk(0)) }; // design 允许的 (rpc, step):其余阶段一律应回 STEP const ALLOWED = { jiaofen: [1], xuanzhu: [2], touxiang: [2], maipai: [3], chupai: [5], mingpai: [5], tishi: [5], zhunbei: [6] }; const STEPS = [1, 2, 3, 5, 6]; const bad = []; let cells = 0; // 实际比对的格数,防止驱动失败导致整行"跳过"而假绿 console.log('阶段机迁移矩阵(行=RPC,列=阶段;✓受理 ×拒(STEP) ?拒(其他码))'); console.log(' step1 step2 step3 step5 step6'); for (const rpc in ALLOWED) { const row = []; for (const st of STEPS) { // touxiang 需要 call=70 才有资格;其余用 65 const { c, pj } = driveTo(st, rpc === 'touxiang' ? 70 : 65); if (pj.step !== st) { row.push(' 跳过'); continue; } c.sent.length = 0; FIRE[rpc](c, pj); const fails = c.sent.filter(m => m.data && m.data.success === false); const accepted = c.sent.length > 0 && fails.length === 0; const stepRejected = fails.length > 0 && fails[0].data.errcode === ERR.STEP; const should = ALLOWED[rpc].indexOf(st) >= 0; let mark; if (accepted) mark = ' ✓ '; else if (stepRejected) mark = ' × '; else if (fails.length) mark = ' ?' + fails[0].data.errcode + ' '; else mark = ' 无包 '; row.push(mark); cells++; if (should && !accepted) bad.push(`${rpc} 在 step${st} 应受理却未受理(${mark.trim()})`); if (!should && accepted) bad.push(`${rpc} 在 step${st} 不应受理却受理了`); if (!should && !accepted && !stepRejected && fails.length) bad.push(`${rpc} 在 step${st} 被拒但错误码不是 STEP(${fails[0].data.errcode})`); if (!should && !fails.length) bad.push(`${rpc} 在 step${st} 既没受理也没回失败包(静默丢弃)`); } console.log(rpc.padEnd(10) + row.join('')); } t.eq('§12.1 阶段机迁移矩阵(8 RPC × 5 阶段 = 40 格)无异常', bad.slice(0, 3), []); t.eq('阶段机矩阵覆盖 40 格', cells, 40); // ===================== 叫分起始者规则 ===================== console.log('\n叫分起始者规则(design §4.2 + §4.6)'); const errs2 = []; const c2 = mkRoom('00000'); D.do_new_paiju(c2.desk, 0); let pj = c2.desk.method.curr_paiju(); if (pj.firstseat !== 0) errs2.push('第一局起始叫分者应为座位0,实为 ' + pj.firstseat); if (pj.method.get_callgrade_seat() !== 0) errs2.push('第一局待叫者应为座位0'); // 首家不能不叫 c2.sent.length = 0; mod.jiaofen(pk(0, { call: 0 })); if (!(c2.sent.length === 1 && c2.sent[0].data.success === false && c2.sent[0].data.errcode === ERR.RULE)) errs2.push('首家"不叫"未被按 RULE 拒'); if (pj.method.get_callgrade_seat() !== 0) errs2.push('首家被拒后仍应轮到他自己'); t.eq('§4.6 第一局起始叫分者 = 座位 0', [pj.firstseat, 0], [0, 0]); // 跨局:起始叫分者 = 暂定庄家(庄赢连庄 / 庄输下家) const seen = { 0: 0, 1: 0, 2: 0 }; for (let r = 1; r <= 8; r++) { const cur = c2.desk.method.curr_paiju(); if (cur.method.get_callgrade_seat() !== cur.firstseat) errs2.push('第' + r + '局待叫者≠firstseat'); // 该局首家不叫必须被拒 c2.sent.length = 0; mod.jiaofen(pk(cur.firstseat, { call: 0 })); if (!(c2.sent.length === 1 && c2.sent[0].data.errcode === ERR.RULE)) errs2.push('第' + r + '局首家不叫未被拒'); // 正常打完这一局 mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 65 })); if (cur.step === 1) mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 0 })); if (cur.step === 1) mod.jiaofen(pk(cur.method.get_callgrade_seat(), { call: 0 })); const b = cur.banker; seen[b]++; mod.xuanzhu(pk(b, { flower: 1 })); mod.maipai(pk(b, { cards: P.get_seat_cards(cur, b).slice(-8) })); let g = 0; while (cur.step === 5 && ++g < 400) { const s = cur.playproc.currseat, h = P.get_seat_cards(cur, s); let p = null; if (s === cur.playproc.start) p = [h[h.length - 1]]; else for (const x of h) if (A.can_followcard(cur.flower, h, [x], cur.playproc.startcount, cur.playproc.startflower, cur.playproc.starttype).result) { p = [x]; break; } if (!p) break; mod.chupai(pk(s, { cards: p })); } const expNext = (cur.result === 0) ? b : (b + 1) % 3; if (r < 6) { mod.zhunbei(pk(0)); mod.zhunbei(pk(1)); mod.zhunbei(pk(2)); const nx = c2.desk.method.curr_paiju(); if (nx === cur) { errs2.push('第' + r + '局后未开新局'); break; } if (nx.firstseat !== expNext) errs2.push('第' + (r + 1) + '局起始叫分者应为 ' + expNext + ',实为 ' + nx.firstseat); } else break; } t.eq('§4.2/§4.6 叫分起始者与首家必叫(连打 6 局)无异常', errs2.slice(0, 3), []); // ===================== 连庄分支(result=0):庄赢时起始叫分者不变 ===================== console.log('\n连庄分支(design §4.6 庄赢→暂定庄家仍为该玩家)'); let got = false; for (let attempt = 0; attempt < 60 && !got; attempt++) { const c3 = mkRoom('00000'); D.do_new_paiju(c3.desk, 0); const p3 = c3.desk.method.curr_paiju(); // 叫 70:闲家要捡满 70 分才算达标,庄家最易守;首家出最大牌以多赢墩 mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 70 })); if (p3.step === 1) mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 0 })); if (p3.step === 1) mod.jiaofen(pk(p3.method.get_callgrade_seat(), { call: 0 })); const b = p3.banker; mod.xuanzhu(pk(b, { flower: 1 })); mod.maipai(pk(b, { cards: P.get_seat_cards(p3, b).slice(-8) })); let g = 0; while (p3.step === 5 && ++g < 400) { const s = p3.playproc.currseat, h = P.get_seat_cards(p3, s); let p = null; if (s === p3.playproc.start) p = [h[0]]; else for (let i = h.length - 1; i >= 0; i--) if (A.can_followcard(p3.flower, h, [h[i]], p3.playproc.startcount, p3.playproc.startflower, p3.playproc.starttype).result) { p = [h[i]]; break; } if (!p) break; mod.chupai(pk(s, { cards: p })); } if (p3.result !== 0) continue; got = true; mod.zhunbei(pk(0)); mod.zhunbei(pk(1)); mod.zhunbei(pk(2)); const nx = c3.desk.method.curr_paiju(); t.eq('§4.6 庄赢(result=0) → 下一局起始叫分者仍是该庄家(连庄)', nx.firstseat, b); // 连庄局的首家同样不能不叫 c3.sent.length = 0; mod.jiaofen(pk(nx.firstseat, { call: 0 })); t.eq('§4.2 连庄局的首家同样不能"不叫"', c3.sent.length === 1 && c3.sent[0].data.errcode === ERR.RULE, true); } t.eq('§4.6 连庄分支已覆盖到(构造出 result=0 的庄赢局)', got, true); process.exit(t.done('flow') ? 0 : 1);