// 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);