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