Files
erqiwang_youle/server/games/erqiwang/test/test_fuzz.js
T
joywayerandClaude Opus 5 9d0675fce8 二七王:第六轮核对(多局大局 + 整局带牌型模糊),未发现不一致
前五轮的端到端都只跑单局、且只出单张。本轮补两个从未被驱动过的维度:

一、多局大局(design §12.1 步骤10 / §12.2 / §4.6)
真实驱动 6 局与 12 局:跨局轮庄与「上局 banker+result」严格对应,另单独造出
result=0 的庄赢局验证连庄端到端(此前只有 do_prepare 的桩单测);每局零和、
累计分与逐局累加一致;account 只在末局出现,打满后再准备被拒;房卡只扣一次;
战绩载荷完整;中途解散按当前累计分结算、result=3。全部相符。

二、整局带牌型模糊(新增 test/test_fuzz.js)
test_endgame 的驱动器只会出单张,对子/拖拉机/甩牌/甩错在整局链路里从未跑过。
新测试用带牌型的对局补上,并对每一墩用独立参考实现重算「谁最大」与服务端
比对(不是抽查),另独立重算捡分、扣底倍数、算奖与捡分子数分配。探针阶段
跑了 160 局约 3800 墩(4 个种子)0 异常,入库版固定为 20 局约 480 墩、0.3s。

变异检验:牌的归属写给非胜者、单张毙牌不再压过副牌、算奖分配去掉 ×X、
跟牌牌面值改回未排序入参 —— 四条全部转红。

过程中修正了测试自身的三个问题(已写进 test/README 与 compliance):
- 参考实现的 rank 取了负数,而 0 是「不参与本墩」的哨兵,哨兵反而数值最大;
- 闲家捡分按 playowner 累加再与 aset.grade 比,是拿被测字段自证,playowner
  写错时两边一起错照样通过 —— 改成按独立算出的墩胜者累计;
- 扣底倍数再调 get_bottom_multiple 去比,同样是自证,倍数表改成恒返回 2 也
  通过 —— 改成按 design §6.3 独立重算。
另:该文件的覆盖下限是发牌相关的,随机发牌下扣底可能一次都不出现(实测 60
次里有 1 次),故把发牌也接到固定种子上;随机发牌的整局覆盖由 test_endgame
承担。

全套单测 511 → 519 项全绿,总耗时 < 1.7s。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 13:58:02 +08:00

291 lines
17 KiB
JavaScript
Raw 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.
// L4 整局模糊 + 每墩胜者差分(design §5/§6/§7/§8/§12)
//
// 为什么需要它:`test_endgame.js` 的驱动器**只会出单张**,所以对子 / 拖拉机 / 甩牌 / 甩错
// 这些牌型在「整局链路」里从未被跑过——单测虽然逐个覆盖了 can_playcard / can_followcard,
// 但它们与 do_playcard / mod.chupai / 结算的**集成**是空白。本文件用带牌型的随机对局补上,
// 并对**每一墩**用独立参考实现重算「谁最大」与服务端比对(不是抽查,是每墩都比)。
//
// 不抖动:随机源是固定种子的 xorshift32,同一份代码每次跑的对局序列完全相同。
// 末尾的「覆盖下限」断言防止造牌/选牌策略退化成全是单张而让本文件形同虚设。
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')();
const GAMES = 20;
let seed = 0x51EED17;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
const clone = m => JSON.parse(JSON.stringify(m));
// 提交顺序随机化:真实客户端可以按任意顺序发 cards,服务端的判定不得依赖入参顺序
// (曾有缺陷:跟牌牌面值用未排序的入参算,乱序提交能把主拖拉机毙牌算成 0 而翻转本墩胜负)
// 一半随机打乱,一半用**最坏顺序**:按大小排好后取偶数位再取奇数位,把每个对子都拆开摆
// ([K,K,Q,Q] → [K,Q,K,Q])。这正是按相邻取对的算法会漏判的形状;纯随机打乱在某些牌局下
// 一次都碰不到,所以必须有确定性的最坏顺序兜底。
const shuffle = a => {
if (rnd(2) === 0) {
const s = a.concat().sort((x, y) => pos(x) - pos(y));
return s.filter((_, i) => i % 2 === 0).concat(s.filter((_, i) => i % 2 === 1));
}
const r = a.concat();
for (let i = r.length - 1; i > 0; i--) { const j = rnd(i + 1); const x = r[i]; r[i] = r[j]; r[j] = x; }
return r;
};
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
global.min_ontimeout = fn => fn();
// 发牌也接到同一个种子上:本文件末尾的「覆盖下限」断言(甩牌/毙牌/扣底各出现多少次)是
// 发牌相关的,若沿用 _shim 的 Math.random 发牌,稀有事件(尤其扣底)会在某些运行里一次都
// 不出现而让下限断言随机转红——实测 60 次里有 1 次 0 次扣底。这里固定发牌序列,把覆盖变成
// 确定事实。随机发牌的整局覆盖由 test_endgame.js 承担,两者互补。
global.min_random = (a, b) => a + rnd(b - a + 1);
// ==================== 独立参考实现:按 design §3/§5/§6 重算「本墩谁最大」====================
let MF;
const num = c => { const m = c % 54; return m === 52 ? 53 : m === 53 ? 54 : m % 13 + 1; };
const flw = c => { const m = c % 54; return m >= 52 ? 5 : Math.floor(m / 13) + 1; };
const isTrump = c => { const n = num(c); return n >= 53 || n === 2 || n === 7 || flw(c) === MF; };
const eflw = c => isTrump(c) ? MF : flw(c); // 主牌一律归主花色
const NORMAL = [1, 13, 12, 11, 10, 9, 8, 6, 5];
function pos(c) { // design §3 等级位置(越小越大)
const n = num(c), f = flw(c);
if (n === 54) return 0; if (n === 53) return 1;
if (n === 7) return f === MF ? 2 : 3; // 正7 / 副7(副7 同级)
if (n === 2) return f === MF ? 4 : 5; // 正2 / 副2
if (f === MF) return 6 + NORMAL.indexOf(n);
return 100 + NORMAL.indexOf(n); // 副牌只在同花色内比较
}
const samecard = (a, b) => num(a) === num(b) && flw(a) === flw(b); // §3 对子=同花色同点数两副
function pairsOf(cs) {
const used = [], ps = [], s = cs.concat().sort((a, b) => pos(a) - pos(b));
for (let i = 0; i < s.length; i++) {
if (used[i]) continue;
for (let j = i + 1; j < s.length; j++) if (!used[j] && samecard(s[i], s[j])) { used[i] = used[j] = 1; ps.push(s[i]); break; }
}
return ps;
}
function isTractor(cs, N) {
const ps = pairsOf(cs);
if (ps.length !== N || cs.length !== 2 * N) return false;
for (let i = 0; i + 1 < ps.length; i++) if (pos(ps[i + 1]) - pos(ps[i]) !== 1) return false;
return true;
}
// 必须取正值:0 是「不参与本墩争夺」的哨兵,rank 若取负数,哨兵 0 反而数值最大(踩过一次)
const topRank = cs => 1000 - Math.min(...cs.map(pos));
// 某家本墩出牌的牌面值。design §5.1/§5.2:垫牌与混合出牌一律 0(不争夺);
// §5.4.4:首家甩牌时跟牌不可能压过;缺门用纯主牌顶同牌型即「毙牌」,加权盖过任何副牌。
function valueOf(cs, F, T, C) {
const efs = {}; cs.forEach(c => efs[eflw(c)] = 1);
const ks = Object.keys(efs);
if (ks.length > 1) return 0; // 混合出牌
const ef = parseInt(ks[0]);
if (ef !== F && ef !== MF) return 0; // 垫别的副牌
let ok;
if (T > 100 && T < 200) ok = (T === 101 && C === 1);
else if (T === 201) ok = (cs.length === 2 && pairsOf(cs).length === 1);
else if (T > 201 && T < 300) ok = false;
else if (T > 300 && T < 400) ok = isTractor(cs, T - 300);
else ok = false;
if (!ok) return 0;
const r = topRank(cs);
return (ef === MF && F !== MF) ? (100000 + r) : r; // 毙牌
}
// design §6.3 扣底倍数:赢末墩的牌须全是主牌;单张 ×1、主对 ×2、N 连对 ×2N;
// 混合甩牌取其中最高规格。返回 0 表示不扣底。
function refBottomMultiple(cards) {
if (!cards.every(isTrump)) return 0;
const ps = pairsOf(cards);
if (ps.length === 0) return 1;
let best = 1, cur = 1;
for (let i = 0; i + 1 < ps.length; i++) {
if (pos(ps[i + 1]) - pos(ps[i]) === 1) { cur++; if (cur > best) best = cur; } else cur = 1;
}
return 2 * best;
}
// ==================== 对局装配与选牌策略 ====================
function mkRoom(rt) {
const sent = [];
const o_room = {
roomtype: rt, asetcount: 6, roomcode: 1, createtime: 'T0', makewartime: 'T1',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P' + i, avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push(clone(m)) }
};
const desk = D.new(o_room); o_room.o_desk = desk;
// class.desk / class.paiju 走全局 youle_erqiwang,mod.js 走自己的模块对象,两处都要接
global.youle_erqiwang.app = { SendPack: m => sent.push(clone(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 };
}
// 首家候选:单张 / 对子 / N 连对 / 随机主牌组合(后者可能是合法甩牌,也可能是甩错)
function leadCandidates(hand) {
const out = hand.map(c => [c]);
const bySuit = {};
hand.forEach(c => (bySuit[eflw(c)] = bySuit[eflw(c)] || []).push(c));
for (const f in bySuit) {
const cs = bySuit[f], ps = pairsOf(cs);
const mate = p => cs.filter(x => x !== p && samecard(x, p))[0];
ps.forEach(p => { if (mate(p) !== undefined) out.push([p, mate(p)]); });
for (let n = 2; n <= 4; n++) for (let s = 0; s + n <= ps.length; s++) {
const run = ps.slice(s, s + n);
let okr = true;
for (let q = 0; q + 1 < run.length; q++) if (pos(run[q + 1]) - pos(run[q]) !== 1) okr = false;
if (!okr) continue;
const t2 = [];
run.forEach(p => { t2.push(p); t2.push(mate(p)); });
if (t2.every(x => x !== undefined)) out.push(t2);
}
}
const tr = hand.filter(isTrump);
if (tr.length >= 2) for (let k = 0; k < 6; k++) {
const n = 2 + rnd(3), sel = [], p2 = tr.concat();
while (sel.length < n && p2.length) sel.push(p2.splice(rnd(p2.length), 1)[0]);
if (sel.length >= 2) out.push(sel);
}
return out;
}
function pickLead(pj, seat) {
const hand = P.get_seat_cards(pj, seat);
const opp = [0, 1, 2].filter(o => o !== seat).map(o => P.get_seat_zhucards(pj, o));
// 甩错候选也保留:mod.chupai 会按 §5.4.5 收回整套、只强制打出最小一张,这条路径也要跑到
const cands = leadCandidates(hand).filter(cs => {
const r = A.can_playcard(pj.flower, cs.concat(), seat, pj.seatlist, opp);
return r.result === true || r.shuaicuo === true;
});
if (!cands.length) return [hand[hand.length - 1]];
cands.sort((a, b) => b.length - a.length);
return rnd(3) === 0 ? cands[cands.length - 1] : cands[rnd(Math.min(4, cands.length))];
}
function pickFollow(pj, seat) {
const hand = P.get_seat_cards(pj, seat);
const C = pj.playproc.startcount, F = pj.playproc.startflower, T = pj.playproc.starttype;
const g = A.get_followcard(pj.flower, hand, C, F, T);
const okPick = p => A.can_followcard(pj.flower, hand, p, C, F, T).result === true
&& (!pj.playproc.shuai_demand || A.flush_follow_ok(pj.flower, hand, p, pj.playproc.shuai_demand));
for (let i = 0; i < 400; i++) { // 先按「必出 + 可出」凑(真实客户端的做法)
const pick = (g.mustcard || []).concat(), pool = (g.cancard || []).concat();
while (pick.length < C && pool.length) pick.push(pool.splice(rnd(pool.length), 1)[0]);
if (pick.length === C && okPick(pick)) return pick;
}
for (let i = 0; i < 800; i++) { // 兜底:整手牌里随机取 C 张
const h = hand.concat(), p = [];
while (p.length < C && h.length) p.push(h.splice(rnd(h.length), 1)[0]);
if (p.length === C && okPick(p)) return p;
}
return null;
}
// ==================== 跑局 ====================
const stats = { rounds: 0, tricks: 0, multi: 0, shuai: 0, shuaicuo: 0, bi: 0, kodi: 0 };
const errs = [];
const chk = (c, m) => { if (!c) errs.push(m); };
const CALLS = [70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5];
const ROOMS = ['00000', '00100', '00010', '00110']; // 常规 / 傍王 / 爬坡 / 傍王+爬坡
for (let game = 0; game < GAMES; game++) {
const c = mkRoom(ROOMS[game % ROOMS.length]);
D.do_new_paiju(c.desk, 0);
const pj = c.desk.method.curr_paiju();
MF = null;
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: CALLS[rnd(CALLS.length)] }));
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 }));
const b = pj.banker; MF = 1 + rnd(4);
mod.xuanzhu(pk(b, { flower: MF }));
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
chk(P.get_seat_cards(pj, b).length === 28, `g${game} §4.5 埋牌后庄家应剩 28 张`);
const allJiesuan = [];
let guard = 0, dead = false, refJian = 0; // refJian:按独立算出的墩胜者累计的闲家捡分
while (pj.step === 5 && ++guard < 400) {
const st = pj.playproc.start;
c.sent.length = 0;
mod.chupai(pk(st, { cards: shuffle(pickLead(pj, st)) }));
const lp = c.sent.filter(m => m.rpc === 'chupai1')[0];
chk(!!lp, `g${game} 首出被拒`);
if (!lp) { dead = true; break; }
if (lp.data.shuaicuo) stats.shuaicuo++;
if (lp.data.shuai) stats.shuai++;
if (lp.data.cards.length > 1) stats.multi++;
const C = pj.playproc.startcount, F = pj.playproc.startflower, T = pj.playproc.starttype;
const trick = [{ seat: st, cards: lp.data.cards }];
let maxseat;
for (let k = 0; k < 2; k++) {
const s2 = pj.playproc.currseat, f2 = pickFollow(pj, s2);
if (f2 === null) { chk(false, `g${game} 找不到合法跟牌 seat${s2} T=${T} C=${C} F=${F}`); dead = true; break; }
chk(f2.length === C, `g${game} §5.2 跟牌张数应等于首出张数`);
const before = c.sent.length;
mod.chupai(pk(s2, { cards: shuffle(f2) }));
const out = c.sent.slice(before);
const ok = out.filter(m => ['chupai2', 'chupai3', 'jiesuan'].indexOf(m.rpc) >= 0);
chk(ok.length > 0 && ok[0].data.success === true, `g${game} 跟牌被拒`);
const fin = out.filter(m => m.rpc === 'jiesuan')[0], c3 = out.filter(m => m.rpc === 'chupai3')[0];
if (fin) { allJiesuan.push(fin); maxseat = fin.data.chupai.maxseat; }
else if (c3) { maxseat = c3.data.maxseat; }
trick.push({ seat: s2, cards: f2 });
}
if (dead) break;
stats.tricks++;
if (F !== MF && trick.slice(1).some(x => x.cards.every(isTrump))) stats.bi++;
// —— 每墩都独立重算胜者并比对 ——
const vals = trick.map(x => valueOf(x.cards, F, T, C));
let bi = 0; for (let v = 1; v < 3; v++) if (vals[v] > vals[bi]) bi = v;
const refWin = trick[bi].seat;
chk(maxseat === refWin,
`g${game} §6.2 墩胜者不符 服务端=${maxseat} 参考=${refWin} T=${T} C=${C} F=${F} MF=${MF} vals=${JSON.stringify(vals)} plays=${JSON.stringify(trick)}`);
// §6.2 本墩全部牌的归属必须是本墩胜者。
// 注意不能只用 aset.grade 去对「按 playowner 累加的捡分」——那是拿同一个字段自证,
// playowner 被写错时两边一起错、断言照样通过(实测过)。这里用**独立算出的胜者**来钉。
trick.forEach(x => x.cards.forEach(cid => {
chk(pj.cards[cid].playowner === refWin, `g${game} §6.2 牌 ${cid} 归属应为本墩胜者 ${refWin},实为 ${pj.cards[cid].playowner}`);
}));
if (refWin !== pj.banker) refJian += trick.reduce((a, x) => a + x.cards.reduce((b, cid) => b + pj.cards[cid].score, 0), 0);
}
if (dead) continue;
chk(pj.step === 6, `g${game} §12.1 未打到结算 step=${pj.step}`);
chk(pj.cards.filter(x => x.playround > 0).length === 84, `g${game} §2 出牌总数应为 84`);
chk(pj.cards.filter(x => x.playround === 0).length === 8, `g${game} §4.5 埋牌应为 8`);
chk(pj.cards.filter(x => x.playround > 0 && x.playowner < 0).length === 0, `g${game} §6.2 有出过的牌无归属`);
chk(allJiesuan.length === 1, `g${game} 结算包应恰好 1 个`);
if (allJiesuan.length === 1) {
const d = allJiesuan[0].data, aset = d.aset;
const bonus = (d.bottom && d.bottom.grade2) || 0;
chk(aset.grade === refJian + bonus, `g${game} §6.2/§6.3 捡分不符 aset=${aset.grade} 独立计=${refJian + bonus}`);
chk(aset.seatlist.reduce((a, p) => a + p.grade, 0) === 0, `g${game} §7/§8 结算不零和`);
// §6.3 扣底:触发条件与**倍数**都要独立算。倍数不能再调 get_bottom_multiple——
// 那是拿被测函数自证,倍数表被改错(比如恒返回 2)照样通过(实测过)。
const lastXian = pj.playproc.maxseat !== pj.banker;
const lastMult = lastXian ? refBottomMultiple(pj.playproc.cards[pj.playproc.maxseat]) : 0;
chk((lastMult > 0) === !!d.bottom.multiple, `g${game} §6.3 扣底触发条件不符`);
if (lastMult > 0) {
chk(d.bottom.multiple === lastMult, `g${game} §6.3 扣底倍数不符 服务端=${d.bottom.multiple} 参考=${lastMult}`);
chk(d.bottom.grade2 === d.bottom.multiple * d.bottom.grade1, `g${game} §6.3 扣底得分应为 倍数×底牌分`);
stats.kodi++;
}
const X = aset.multiple * Math.abs(aset.upgrade), N = aset.seatlist.map(p => p.naward);
chk(JSON.stringify(aset.seatlist.map(p => p.grade_aw)) ===
JSON.stringify([X * (2 * N[0] - N[1] - N[2]), X * (2 * N[1] - N[0] - N[2]), X * (2 * N[2] - N[0] - N[1])]),
`g${game} §8.4 算奖分配公式不符`);
chk(JSON.stringify(aset.seatlist.map(p => p.grade_jf)) ===
JSON.stringify([0, 1, 2].map(s => (aset.upgrade > 0 ? 1 : -1) * X * (s === pj.banker ? 2 : -1))),
`g${game} §7.0 捡分子数分配不符`);
}
stats.rounds++;
}
t.eq('整局模糊 无异常', errs.slice(0, 3), []);
t.eq('整局模糊 全部跑完', stats.rounds, GAMES);
// 覆盖下限:发牌与选牌都已定种子,这些数字是确定事实(实测 20 局:474 墩 / 多张 53 /
// 甩牌 11 / 甩错 178 / 毙牌 70 / 扣底 6)。阈值取略低于实测值——既能挡住「选牌策略退化成
// 全是单张」让本文件形同虚设,又不会因无害的重构而误报。
t.eq('覆盖 墩数 ≥ 400', stats.tricks >= 400, true);
t.eq('覆盖 多张首出 ≥ 40 次', stats.multi >= 40, true);
t.eq('覆盖 合法甩牌 ≥ 8 次', stats.shuai >= 8, true);
t.eq('覆盖 甩错 ≥ 100 次', stats.shuaicuo >= 100, true);
t.eq('覆盖 毙牌墩 ≥ 40 次', stats.bi >= 40, true);
t.eq('覆盖 扣底 ≥ 3 局', stats.kodi >= 3, true);
console.log(` [统计] ${stats.rounds} 局 / ${stats.tricks} 墩:多张首出 ${stats.multi}、合法甩牌 ${stats.shuai}、甩错 ${stats.shuaicuo}、毙牌墩 ${stats.bi}、扣底 ${stats.kodi} 局`);
process.exit(t.done('fuzz') ? 0 : 1);