规则方复述「开局庄家先叫分、不允许不叫分;第一局无庄家时默认座位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>
196 lines
11 KiB
JavaScript
196 lines
11 KiB
JavaScript
// 流程完整性(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);
|