Files
joywayerandClaude Opus 5 b924b6e864 二七王:第八轮核对(流程/牌型/玩法/算分)+ 新增阶段机迁移矩阵用例
规则方复述「开局庄家先叫分、不允许不叫分;第一局无庄家时默认座位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>
2026-08-23 14:59:46 +08:00

196 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 流程完整性(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);