前五轮的端到端都只跑单局、且只出单张。本轮补两个从未被驱动过的维度: 一、多局大局(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>
291 lines
17 KiB
JavaScript
291 lines
17 KiB
JavaScript
// 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);
|