Files
erqiwang_youle/server/games/erqiwang/test/test_followdiff.js
T
joywayerandClaude Opus 5 1b76683704 二七王:固化两组 design 差分测试(§7 全量对拍 + §5.2 穷举差分)
把第五轮复验用的取证探针固化入库。两者都不枚举「我想到的场景」,而是把
design 的规则整条转写成参考实现再与代码大面积对拍,专治手写用例覆盖不到
的欠约束分支:

- test_calc.js:§7 算子全量对拍。参考实现逐字转写自 §7.1/§7.2/§7.3 判定表,
  比对 2 种算子模式 × 14 个叫分档 × grade 0~260 共 7308 格(每格比
  base/Q/判定倍率/最终子数),另按 design 的 17 张表逐格抽查 141 个写死的
  最终子数。断言按档位分组,失败可直接定位。
- test_followdiff.js:§5.2 跟牌强制层级穷举差分。对每手牌枚举全部 C 张出牌
  组合,逐个比对参考裁定与 can_followcard,约 11 万组。

两组均通过变异检验:关掉 follow_tractor_cover_ok → followdiff 三种拖拉机
首出全红;大光倍率 3→4 / 常规55档 base 4→5 / 爬坡40·35档 Q 20→40 → calc
精确指出档位与 grade。

过程中的教训已写进 test/README 与 compliance:差分测试的强度取决于造牌器。
第一版纯随机抽牌跑 7.8 万组全绿,但关掉覆盖度校验依然全绿——「同花色三组
互不相邻的两连对」这种唯一能区分「只出零散对子」与「先凑最长拖拉机」的
12 张结构随机抽不出来;掺牌又一度把它撑过枚举上限被静默跳过。故随机源改用
固定种子 PRNG,关键结构用确定性 structHands() 钉死。

全套单测 401 → 462 项全绿,总耗时 < 1.5s。

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

237 lines
12 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.
// §5.1/§5.2 跟牌强制层级 · 穷举差分测试
//
// 做法:把 design §5.2 的裁定规则**按原文另写一份参考实现**(本文件上半部分,只依据 design
// 的文字,不看 class.arith 的实现),再随机造手牌、**枚举该手牌的全部 C 张出牌组合**,
// 逐个比对「参考实现允许 vs can_followcard 允许」。任何一侧多放行或多拒绝都会被抓到。
//
// 这类差分比 test_follow.js 的手写正反例更能兜住实现里的欠约束分支
// (get_followcard 降级递归的 else 分支就曾在这类场景漏判)。
// 随机源用**固定种子的 PRNG**,不用 Math.random —— 同一份代码每次跑的用例集完全相同,不抖动。
require('./_shim');
const A = require('../class.arith.js');
const t = require('./_assert')();
const MF = 4; // 主花色 = 黑桃
const id = (d, f, n) => (d - 1) * 54 + (f - 1) * 13 + (n - 1);
const WG = (d, big) => (d - 1) * 54 + (big ? 53 : 52);
// ==================== 参考实现(逐条转写自 design §3/§5.2/§5.3)====================
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; };
// §3:主牌 = 大小王 + 所有花色的 2 和 7 + 主花色普通牌
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);
// §3 主牌顺序 / §5.3 相邻链上的位置(越小越大);副7、副2 各自只占一个等级
const NORMAL = [1, 13, 12, 11, 10, 9, 8, 6, 5]; // A K Q J 10 9 8 6 5
function pos(c) {
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;
if (n === 2) return f === MF ? 4 : 5;
if (f === MF) return 6 + NORMAL.indexOf(n);
return 100 + NORMAL.indexOf(n); // 副牌:只在同花色内比较
}
// §3「对子指同一张具体牌的两副」= 同花色同点数
const samecard = (a, b) => num(a) === num(b) && flw(a) === flw(b);
// 取对子(返回每对的代表牌,按大到小)
function pairsOf(cs) {
const used = [], ps = [];
for (let i = 0; i < cs.length; i++) {
if (used[i]) continue;
for (let j = i + 1; j < cs.length; j++) {
if (!used[j] && samecard(cs[i], cs[j])) { used[i] = used[j] = 1; ps.push(cs[i]); break; }
}
}
ps.sort((a, b) => pos(a) - pos(b));
return ps;
}
// §5.3:相邻对子连成拖拉机 → 各连对段的长度,降序(孤立对子长度 1)
function runsOf(cs) {
const ps = pairsOf(cs), runs = [];
let cur = 1;
for (let i = 0; i + 1 < ps.length; i++) {
if (pos(ps[i + 1]) - pos(ps[i]) === 1) cur++;
else { runs.push(cur); cur = 1; }
}
if (ps.length) runs.push(cur);
runs.sort((a, b) => b - a);
return runs;
}
// §5.2 层级2「能凑多长的拖拉机,就必须先凑多长」→ 最长优先的贪心规格档案
function bestProfile(runs, k) {
const av = runs.concat().sort((a, b) => b - a), p = [];
let budget = k, i = 0;
while (budget > 0 && i < av.length) { const take = Math.min(av[i], budget); p.push(take); budget -= take; i++; }
return p;
}
function lessProfile(a, b) {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) { if (a[i] < b[i]) return true; if (a[i] > b[i]) return false; }
return a.length < b.length;
}
// 参考裁定:这手 X 是不是 design §5.2 允许的跟牌
function refOk(hand, X, C, F, T) {
if (X.length !== C) return false;
const S = hand.filter(c => eflw(c) === F);
if (S.length === 0) return true; // §5.2 完全缺门:整手可任意出
if (S.length < C) return S.every(c => X.indexOf(c) >= 0); // §5.2 该花色不够:全出 + 缺口任意补
if (X.filter(c => eflw(c) === F).length !== C) return false; // 够:必须全打同花色
if (T > 100 && T < 200) return true; // 单张 / 多张单张
if (T === 201) return pairsOf(S).length === 0 ? true : pairsOf(X).length >= 1; // 有对必出对
if (T > 201 && T < 300) return true; // 多对(甩牌压平):分量约束在 flush_follow_ok
if (T > 300 && T < 400) { // N 连对拖拉机
const N = T - 300, k = Math.min(pairsOf(S).length, N); // 能凑几对就必须凑几对(层级 3/4/5)
if (pairsOf(X).length !== k) return false;
return !lessProfile(runsOf(X), bestProfile(runsOf(S), k)); // 且拖拉机规格不得劣于最优(层级 1/2)
}
return true;
}
// ==================== 穷举差分 ====================
// 固定种子的 xorshift32,保证用例集可复现
let _seed = 0x2717CAFE;
function rnd(n) { _seed ^= _seed << 13; _seed ^= _seed >>> 17; _seed ^= _seed << 5; _seed >>>= 0; return _seed % n; }
function combos(arr, k) {
const out = [];
(function rec(st, cur) {
if (cur.length === k) { out.push(cur.slice()); return; }
for (let i = st; i < arr.length; i++) { cur.push(arr[i]); rec(i + 1, cur); cur.pop(); }
})(0, []);
return out;
}
// 造牌器必须**按牌型结构**造手牌,不能纯随机抽:纯随机几乎抽不出「同花色 3 对以上、
// 且含多组不同长度拖拉机」的手,而 §5.2 层级 1/2 的强制约束恰恰只在那种手上才生效
// (实测纯随机版关掉 follow_tractor_cover_ok 也全绿 = 差分形同虚设)。
// 这里按「等级表」逐级选取 0/1/2 张,天然产出富对子、富连对的手牌。
// 副花色(梅花)的等级表:A K Q J 10 9 8 6 5(相邻即连对)
const SUB_RANKS = NORMAL.map(n => [id(1, 2, n), id(2, 2, n)]);
// 主牌的等级表 = design §5.3 的相邻链:大王-小王-正7-副7-正2-副2-主A…主5
const TRUMP_RANKS = [
[WG(1, true), WG(2, true)], // 大王
[WG(1, false), WG(2, false)], // 小王
[id(1, MF, 7), id(2, MF, 7)], // 正7
[id(1, 3, 7), id(2, 3, 7)], // 副7(红心)
[id(1, MF, 2), id(2, MF, 2)], // 正2
[id(1, 3, 2), id(2, 3, 2)] // 副2(红心)
].concat(NORMAL.map(n => [id(1, MF, n), id(2, MF, n)]));
// 同级但不同花色的另一张副7/副2(§3:跨花色不成对,用于压边界)
const SAME_LEVEL = [id(1, 2, 7), id(2, 2, 7), id(1, 2, 2), id(2, 2, 2)];
// 垫牌用的第三花色
const FILLER = [];
[1, 13, 12, 10, 8, 5].forEach(n => FILLER.push(id(1, 3, n), id(2, 3, n)));
function buildHand(F, C) {
const ranks = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
// 覆盖 §5.2 的四类分支:完全缺门 / 该花色不够 / 恰好够 / 有富余
const mode = rnd(4);
let targetSuit;
if (mode === 0) targetSuit = 0;
else if (mode === 1) targetSuit = 1 + rnd(Math.max(1, C - 1));
else if (mode === 2) targetSuit = C;
else targetSuit = C + rnd(4);
const suit = [];
// 从随机起点顺链扫,每级取 0/1/2 张(偏向取 2 张以造出对子与连对)
let idx = rnd(ranks.length);
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
const r = ranks[(idx + step) % ranks.length];
const w = rnd(10);
const take = w < 5 ? 2 : w < 8 ? 1 : 0;
for (let q = 0; q < take && suit.length < targetSuit; q++) suit.push(r[q]);
}
// 顺链一圈还没凑够就再补(保证 targetSuit 尽量达成)
for (let step = 0; step < ranks.length && suit.length < targetSuit; step++) {
const r = ranks[step];
for (let q = 0; q < 2 && suit.length < targetSuit; q++) if (suit.indexOf(r[q]) < 0) suit.push(r[q]);
}
// 同级不同花色的副7/副2 偶尔掺一张(只在主牌为跟牌花色时才算同花色)
if (F === MF && rnd(6) === 0) suit.push(SAME_LEVEL[rnd(SAME_LEVEL.length)]);
// 非该花色的补充牌:垫牌花色 + (副牌首出时)主牌,用于毙牌/垫牌/混合出牌分支
const others = [];
const otherPool = (F === MF) ? FILLER.concat() : FILLER.concat(TRUMP_RANKS.reduce((a, r) => a.concat(r), []));
const oc = rnd(4);
for (let i = 0; i < oc && otherPool.length; i++) others.push(otherPool.splice(rnd(otherPool.length), 1)[0]);
// 去重后返回(同级不同花色那一张可能与 others 撞车)
const seen = {}, hand = [];
suit.concat(others).forEach(c => { if (!seen[c]) { seen[c] = 1; hand.push(c); } });
return hand;
}
// [张数, 首家花色, 首家牌型, 说明]
const LEADS = [
[1, 2, 101, '副牌单张'],
[2, 2, 201, '副牌对子'],
[4, 2, 302, '副牌两连对'],
[6, 2, 303, '副牌三连对'],
[8, 2, 304, '副牌四连对'],
[1, MF, 101, '主牌单张'],
[2, MF, 201, '主牌对子'],
[4, MF, 302, '主牌两连对'],
[6, MF, 303, '主牌三连对'],
[3, MF, 103, '主牌甩三单张(压平)'],
[4, MF, 202, '主牌甩两对(压平)']
];
// 结构化手牌(确定性):随机造牌打不到的关键结构在此显式钉住,
// 不让 PRNG 决定 §5.2 层级 1/2 这类强制约束到底有没有被覆盖。
// 例如「同花色三组互不相邻的两连对」共 12 张,是唯一能让「只出零散对子」与
// 「先凑最长拖拉机」产生不同结果的形状——少于 3 组时任取 3 对必含相邻对,测不出差别。
function structHands(F) {
const R = (F === MF) ? TRUMP_RANKS : SUB_RANKS;
const p = i => R[i]; // 第 i 级的一对
const one = i => [R[i][0]]; // 第 i 级的单张
const cat = (...xs) => [].concat(...xs);
return [
cat(p(0), p(1), p(3), p(4), p(6), p(7)), // 三组两连对(互不相邻)
cat(p(0), p(1), p(2), p(4), p(5), p(7)), // 一个三连对 + 一个两连对 + 一个孤立对
cat(p(0), p(2), p(4), p(6), p(8)), // 五个孤立对(无任何拖拉机)
cat(p(0), p(1), p(2), p(3), p(5)), // 一个四连对 + 一个孤立对
cat(p(0), p(1), p(3), one(5), one(7), one(8)), // 两连对 + 孤立对 + 三张单
cat(p(0), one(2), one(4), one(6), one(8)), // 一对 + 四张单(对子不够)
cat(one(0), one(1), one(2), one(3), one(4), one(5), one(6)), // 全单张(无对)
cat(p(0), p(1), p(2)), // 恰好一个三连对
cat(p(0), p(1)), // 恰好一个两连对
cat(one(0), one(1)), // 两张单
[], // 完全缺门
cat(one(0)), // 该花色仅 1 张(不够)
cat(p(0), one(2)) // 该花色 3 张(对子 + 单)
].map(suit => {
// 掺几张别的花色,制造垫牌 / 毙牌 / 混合出牌分支;
// 但同花色已经很多时不再掺——否则手牌被撑过枚举上限、整条用例会被静默跳过,
// 而「三组两连对」这类 12 张的关键结构恰恰就在上限边缘
const extra = (F === MF) ? FILLER : FILLER.concat([TRUMP_RANKS[0][0], TRUMP_RANKS[2][0], TRUMP_RANKS[6][0], TRUMP_RANKS[7][0]]);
return suit.concat(extra.slice(0, Math.max(0, Math.min(3, 12 - suit.length))));
});
}
const TRIALS_PER_LEAD = 320;
let grandCombos = 0;
for (const [C, F, T, desc] of LEADS) {
let combosCount = 0;
const bad = [];
const hands = structHands(F);
for (let k = 0; k < TRIALS_PER_LEAD; k++) hands.push(buildHand(F, C));
for (const hand of hands) {
if (hand.length < C || hand.length > 13) continue; // 组合数上界保护
for (const X of combos(hand, C)) {
combosCount++;
const got = A.can_followcard(MF, hand, X, C, F, T).result === true;
const exp = refOk(hand, X, C, F, T);
if (got !== exp && bad.length < 3) bad.push({ hand: hand.slice(), play: X.slice(), code: got, design: exp });
else if (got !== exp) bad.push('...');
}
}
grandCombos += combosCount;
t.eq('§5.2 穷举差分 ' + desc + '(' + combosCount + ' 组)失配', bad, []);
}
// 钉住覆盖规模:枚举数量若骤降说明造牌逻辑退化、差分形同虚设
t.eq('§5.2 差分覆盖组合数 ≥ 20000', grandCombos >= 20000, true);
process.exit(t.done('followdiff') ? 0 : 1);