二七王:第六轮核对(多局大局 + 整局带牌型模糊),未发现不一致

前五轮的端到端都只跑单局、且只出单张。本轮补两个从未被驱动过的维度:

一、多局大局(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>
This commit is contained in:
2026-08-23 13:58:02 +08:00
co-authored by Claude Opus 5
parent a4361a802d
commit 9d0675fce8
3 changed files with 319 additions and 3 deletions
@@ -193,6 +193,30 @@ F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。
---
## 第六轮核对(多局大局 + 整局带牌型模糊)
> 前五轮的端到端**都只跑单局、且只出单张**。本轮补两个从未被驱动过的维度:① `design §12.1 步骤10 → §12.2` 的**多局大局**(跨局轮庄、累计分、大局结算、战绩);② **带牌型的整局链路**(对子/拖拉机/甩牌/甩错/毙牌/扣底与 `do_playcard`→`mod.chupai`→结算的集成)。**未发现任何不一致。**
| 维度 | 取证方式 | 结果 |
| --- | --- | --- |
| §12.1 步骤10 · §4.6 跨局轮庄 | 真实驱动 6 局(RPC 全链路,含每局 `zhunbei` 开新局) | 每局 `firstseat` 与「上局 banker + result」严格对应;另单独构造出 `result=0` 的**庄赢局**验证**连庄**(此前只有 `do_prepare` 的桩单测,端到端从未走过)✅ |
| §7 累计分 | 6 局 | 每局零和;`desk.seatlist[i][0]` == 逐局累加;`aset.seatlist[i].score` == 累计分 ✅ |
| §12.2 大局结算 | 6 局房与 12 局房各跑满 | `account` **只在末局**出现(6 局房第 6 局、12 局房第 12 局);打满后再 `zhunbei` 回 RULE 拒 ✅ |
| §10.1 扣房卡 | 6 局 | 全程只扣一次(第一局结算),`save_grade` 一次 ✅ |
| 战绩 | `save_grade` 载荷 | `gameinfo1.asetcount`/`players.score` 与累计分一致;`gameinfo2` 每局 `seatlist`/`banker`/`call`/`flower`/`result` 齐全 ✅ |
| §12.2 中途解散 | 打完 2 局后在第 3 局解散 | 解散局 `multiple/upgrade` 均 0、三家得分 `[0,0,0]`、`result=3`;`account` 累计分 == 解散前累计分("按当前累计分结算")✅ |
| 牌局隔离 | 每局开新 paiju | 新局重新发满 92 张;上一局的 `tmp_jiesuan_aset` 已清理 ✅ |
| §5/§6/§7/§8 整局带牌型 | 160 局 / 约 3800 墩(4 个种子 × 40 局),**每一墩**用独立参考实现重算胜者与服务端比对 | 多张首出、合法甩牌、甩错、毙牌墩、扣底均大量出现;牌张守恒 84/8/16、捡分守恒、结算零和、扣底触发条件、算奖分配公式**全部相符,0 异常** ✅ |
**本轮两次"疑似缺陷"经查都不是**(勿再重提):
- 某局结算 `[0,0,0]`:是 `jf=[4,-8,4]` 与 `aw=[-4,8,-4]` 恰好抵消,不是漏算。
- 首轮 40 次尝试跑不出庄赢局:是**用例设计反了**——叫 5 分是闲家最容易达标的档(`grade ≥ 5` 即赢),应叫 70 分才易守。改后立刻出现。
**固化产物**:新增 `test/test_fuzz.js`(整局模糊 + 每墩胜者差分,20 局约 480 墩,8 项聚合断言,0.3s)。多局大局部分为一次性探针未入库(跑满 12 局较慢,且其不变量已由 `test_desk`/`test_endgame`/本轮探针共同覆盖)。全套单测 **511 → 519 项**全绿。
---
## 结论摘要
> **注**:下表是**首轮核对**的初始发现快照(保留以记录起点)。其中 §4.4、§5.4、§6.3、§7、§8.1-8.4、§9、§10、§11 等标 🟥/🟧/🟨 的项**后续均已修复并验证**——当前逐节状态以本文档后面的分节详情及全套单测(270 项全绿)为准。
+5 -3
View File
@@ -44,13 +44,15 @@ node server/games/erqiwang/test/test_arith.js # 单独跑某一组
- `test_calc.js`:**§7 算子全量对拍**。参考实现逐字转写自 §7.1/§7.2/§7.3 的判定表,与 `get_base_bycall` / `get_qvalue` / `get_upgrade` 比对 **2 种算子模式 × 14 个叫分档 × grade 0~260 = 7308 格**(每格比 base/Q/判定倍率/最终子数四项),另按 design 的 17 张表逐格抽查 141 个"写死的最终子数"。断言按(模式,叫分档)与表名分组,失败能直接定位到档位。
- `test_followdiff.js`:**§5.2 跟牌强制层级穷举差分**。参考裁定按 §5.2 原文写(缺门/不足/够牌 × 单张/对子/N 连对 + 最长拖拉机优先的档案比较),对每手牌**枚举全部 C 张出牌组合**逐个比对"参考允许 vs `can_followcard` 允许",任何一侧多放行或多拒绝都会被抓到(约 11 万组)。
- `test_fuzz.js`:**整局模糊 + 每墩胜者差分**。`test_endgame.js` 的驱动器只会出单张,所以对子/拖拉机/甩牌/甩错在「整局链路」里从未跑过——单测逐个覆盖了 `can_playcard`/`can_followcard`,但它们与 `do_playcard`/`mod.chupai`/结算的**集成**是空白。本文件用带牌型的对局补上(20 局约 480 墩,四种房间选项组合轮换),并对**每一墩**用独立参考实现重算"谁最大"与服务端比对(不是抽查,每墩都比),另独立重算闲家捡分、扣底倍数、算奖与捡分子数的分配。提交时还会把牌按**最坏顺序**摆(`[K,K,Q,Q]→[K,Q,K,Q]`,把每个对子拆开),压服务端判定的入参顺序无关性。
这两组都做过**变异检验**(在副本上注入缺陷确认会转红):关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;把大光倍率改 3→4、常规 55 档 base 改 4→5、爬坡 40/35 档 Q 改 20→40 → `test_calc` 精确指出档位与 grade。
三组都做过**变异检验**(在副本上注入缺陷确认会转红):关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率改 3→4 / 常规 55 档 base 改 4→5 / 爬坡 40·35 档 Q 改 20→40 → `test_calc` 精确指出档位与 grade;牌的归属写给非胜者、单张毙牌不再压过副牌、算奖分配去掉 `×X`、跟牌牌面值改回未排序入参 → `test_fuzz` 全部转红。
两点写法约定,改这两个文件时别破坏:
改这三个文件时别破坏的写法约定:
- **随机源必须是固定种子的 PRNG**(`test_followdiff.js` 里的 xorshift32),不用 `Math.random`——同一份代码每次跑的用例集完全相同,不抖动。
- **随机源必须是固定种子的 PRNG**,不用 `Math.random`——同一份代码每次跑的用例集完全相同。`test_fuzz.js` 连**发牌**也接到同一个种子上:它末尾的覆盖下限(甩牌/毙牌/扣底各出现多少次)是发牌相关的,用随机发牌时稀有事件会在某些运行里一次都不出现而让断言随机转红(实测 60 次里有 1 次 0 次扣底)。随机发牌的整局覆盖由 `test_endgame.js` 承担,两者互补。
- **关键结构要用确定性手牌钉死**,不能指望随机造牌覆盖到。例如「同花色三组互不相邻的两连对」(12 张)是唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状(少于 3 组时任取 3 对必含相邻对,测不出差别);纯随机造牌造不出它,关掉覆盖度校验也全绿,差分就形同虚设。`structHands()` 就是为此存在的,另注意别让掺入的其他花色把手牌撑过枚举上限而被静默跳过。
- **不变量不能拿被测字段自证**。`test_fuzz` 里踩过两次:① 闲家捡分若按 `playowner` 累加再与 `aset.grade` 比,`playowner` 写错时两边一起错、断言照样通过——改成按**独立算出的墩胜者**累计;② 扣底倍数若再调 `get_bottom_multiple` 去比,倍数表被改成恒返回 2 也通过——改成按 design §6.3 独立重算。
## 约定(dev-guide §10 测试纪律)
+290
View File
@@ -0,0 +1,290 @@
// 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);