二七王:第八轮核对(流程/牌型/玩法/算分)+ 新增阶段机迁移矩阵用例

规则方复述「开局庄家先叫分、不允许不叫分;第一局无庄家时默认座位0开始叫分」,
据此按四个维度重新取证,未发现不一致。

新增此前从未做过的取证:**阶段机迁移矩阵**。前几轮每个 handler 只测了 1~2 条
代表性失败路径,从没系统验证过「某个请求在别的阶段会不会被误放行、会不会静默
丢弃」。本轮把 8 个 RPC × 5 个阶段做成 40 格矩阵逐格驱动,全部符合:每个 RPC
只在应允阶段受理,其余一律回 STEP 失败包,无静默丢弃。

叫分起始者规则逐条取证,与规则方复述一致:
- 第一局 firstseat=0、待叫者=0
- 之后每局起始 = 暂定庄家:连打 6 局逐局比对「上局 banker + result」
- 庄赢连庄:随机对局几乎打不出 result=0,用「叫70分 + 首家出最大牌」专门构造
- 庄输/投降顺延下家
- 首家不允许「不叫」:第一局与其后每局(含连庄局)发 call=0 一律回 RULE,
  且叫分过程不变、仍轮到他自己

固化为 test/test_flow.js(7 项聚合断言,0.3s)。断言里带 cells===40,防止驱动
失败导致整行「跳过」而假绿。变异检验:去掉 chupai/maipai 的阶段校验、把 jiaofen
的阶段校验改成静默丢弃 —— 三条全部转红。

全套单测 540 → 547 项全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 14:59:46 +08:00
co-authored by Claude Opus 5
parent 31011e2eeb
commit b924b6e864
3 changed files with 238 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
// 流程完整性(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);