二七王:固化两组 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>
This commit is contained in:
@@ -38,6 +38,20 @@ node server/games/erqiwang/test/test_arith.js # 单独跑某一组
|
||||
- `test_endgame.js`(L4 端到端,用真实发牌跑完整一局):叫分→上庄→选主→埋牌→28 轮出牌→小局结算的流程闭合(§12.1),以及与牌面无关的不变量(牌张守恒 84/8/16、捡分=闲家赢得分牌+扣底分、`X` 分配庄±2X/闲∓X、算奖 `X×(2Ni−Nj−Nk)`、结算零和、`result` 与判定一致);另覆盖 §4.4/§7.1 投降局与 §12.2 中途解散两条支线。
|
||||
- `test_rpc.js`(L3,用 `_rpc.js` 装配 mod.js + 捕获下发包):明牌 mingpai、出牌 chupai 的 seatlist/baozhu 查牌门控(§9,含「seatlist 整表带三家」)、投降 touxiang 条件、jiaofen 入参校验、70分上庄暗牌亮牌下发(§4)。
|
||||
|
||||
### 两组差分测试(按 design 原文另写参考实现,与代码对拍)
|
||||
|
||||
下面两组与上面的手写用例互补:它们不枚举"我想到的场景",而是**把 design 的规则整条转写成参考实现**,再和代码大面积比对,专治手写用例覆盖不到的欠约束分支。
|
||||
|
||||
- `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 万组)。
|
||||
|
||||
这两组都做过**变异检验**(在副本上注入缺陷确认会转红):关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;把大光倍率改 3→4、常规 55 档 base 改 4→5、爬坡 40/35 档 Q 改 20→40 → `test_calc` 精确指出档位与 grade。
|
||||
|
||||
两点写法约定,改这两个文件时别破坏:
|
||||
|
||||
- **随机源必须是固定种子的 PRNG**(`test_followdiff.js` 里的 xorshift32),不用 `Math.random`——同一份代码每次跑的用例集完全相同,不抖动。
|
||||
- **关键结构要用确定性手牌钉死**,不能指望随机造牌覆盖到。例如「同花色三组互不相邻的两连对」(12 张)是唯一能区分"只出零散对子"与"先凑最长拖拉机"的形状(少于 3 组时任取 3 对必含相邻对,测不出差别);纯随机造牌造不出它,关掉覆盖度校验也全绿,差分就形同虚设。`structHands()` 就是为此存在的,另注意别让掺入的其他花色把手牌撑过枚举上限而被静默跳过。
|
||||
|
||||
## 约定(dev-guide §10 测试纪律)
|
||||
|
||||
- 测试代码可用现代 JS(不受严格 ES5 限制);**正式代码不得为测试放宽/加后门**。
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// §7 算子全量对拍:把 design §7.1/§7.2/§7.3 的判定表**按原文另写一份参考实现**,
|
||||
// 再与 class.arith 的 get_base_bycall / get_qvalue / get_upgrade 逐格比对。
|
||||
// 参考实现只依据 design 的文字与表格,不看 class.arith 的实现,属差分测试而非同义反复。
|
||||
// 覆盖:2 种算子模式 × 14 个叫分档 × grade 0~260,比 base / Q / 判定倍率 / 最终子数 X 四项。
|
||||
require('./_shim');
|
||||
const A = require('../class.arith.js');
|
||||
const t = require('./_assert')();
|
||||
|
||||
// ============ 参考实现(逐字转写自 design)============
|
||||
// §7.1 常规算子:65→2 60→3 55→4 50及以下统一6;70 打牌→2
|
||||
// §7.3.1 爬坡:65 起每低 5 分加 1 个子,50→6 后继续延伸到 5 分→15;70 仍为 2
|
||||
const CLIMB_BASE = { 65: 2, 60: 3, 55: 4, 50: 6, 45: 7, 40: 8, 35: 9, 30: 10, 25: 11, 20: 12, 15: 13, 10: 14, 5: 15 };
|
||||
function refBase(climb, call) {
|
||||
if (call === 70) return 2; // §7.1 末尾:70 分打牌基础子数 2
|
||||
if (!climb) return (call === 65) ? 2 : (call === 60) ? 3 : (call === 55) ? 4 : 6;
|
||||
return CLIMB_BASE[call];
|
||||
}
|
||||
// §7.2.0 常规算子:小光/过庄分界固定 40,升级级距同为 40
|
||||
// §7.3.2 爬坡:≥45→40、40/35→20、30/25→15、20/15→10、10→5、5→5;70 分不适用分段,仍 40
|
||||
function refQ(climb, call) {
|
||||
if (!climb) return 40;
|
||||
if (call === 70 || call >= 45) return 40;
|
||||
if (call === 40 || call === 35) return 20;
|
||||
if (call === 30 || call === 25) return 15;
|
||||
if (call === 20 || call === 15) return 10;
|
||||
return 5;
|
||||
}
|
||||
// §7.2.0 判定顺序:**必须先判是否达标(升级),再判大光/小光/过庄**
|
||||
// 返回带符号倍率:大光 +3 / 小光 +2 / 过庄 +1 / 升 N 级 −N
|
||||
function refUpgrade(call, grade, Q) {
|
||||
if (grade >= call) return -(Math.floor((grade - call) / Q) + 1);
|
||||
if (grade === 0) return 3;
|
||||
if (grade < Q) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ============ 全量对拍 ============
|
||||
const CALLS = [70, 65, 60, 55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5];
|
||||
let totalCells = 0;
|
||||
for (const climb of [false, true]) {
|
||||
const tag = climb ? '爬坡' : '常规';
|
||||
for (const call of CALLS) {
|
||||
const eb = refBase(climb, call), eq = refQ(climb, call);
|
||||
const gb = A.get_base_bycall(call, climb), gq = A.get_qvalue(call, climb);
|
||||
const bad = [];
|
||||
if (gb !== eb) bad.push(['base', gb, eb]);
|
||||
if (gq !== eq) bad.push(['Q', gq, eq]);
|
||||
for (let g = 0; g <= 260; g++) {
|
||||
const exp = refUpgrade(call, g, eq);
|
||||
const got = A.get_upgrade(call, g, gq);
|
||||
totalCells++;
|
||||
if (got !== exp) { bad.push(['判定 grade=' + g, got, exp]); continue; }
|
||||
// §7.0 第3层:X = 基础子数 × |判定倍率|
|
||||
if (gb * Math.abs(got) !== eb * Math.abs(exp)) bad.push(['X grade=' + g, gb * Math.abs(got), eb * Math.abs(exp)]);
|
||||
}
|
||||
t.eq('§7 全量对拍 ' + tag + ' 叫' + call + ' 分(base/Q/grade0~260)失配', bad.slice(0, 3), []);
|
||||
}
|
||||
}
|
||||
t.eq('§7 全量对拍覆盖格数', totalCells, 2 * CALLS.length * 261);
|
||||
|
||||
// ============ design 判定表里「写死的最终子数」逐格抽查 ============
|
||||
// [climb, call, grade, 期望最终子数 X, 期望判定(G大光/X小光/P过庄/Un升n级)]
|
||||
const TABLES = {
|
||||
'§7.2.1 常规 65 分坐庄': [[0, 65, 0, 6, 'G'], [0, 65, 1, 4, 'X'], [0, 65, 39, 4, 'X'], [0, 65, 40, 2, 'P'], [0, 65, 64, 2, 'P'], [0, 65, 65, 2, 'U1'], [0, 65, 104, 2, 'U1'], [0, 65, 105, 4, 'U2'], [0, 65, 144, 4, 'U2'], [0, 65, 145, 6, 'U3']],
|
||||
'§7.2.1 常规 60 分坐庄': [[0, 60, 0, 9, 'G'], [0, 60, 39, 6, 'X'], [0, 60, 40, 3, 'P'], [0, 60, 59, 3, 'P'], [0, 60, 60, 3, 'U1'], [0, 60, 99, 3, 'U1'], [0, 60, 100, 6, 'U2'], [0, 60, 139, 6, 'U2'], [0, 60, 140, 9, 'U3']],
|
||||
'§7.2.1 常规 55 分坐庄': [[0, 55, 0, 12, 'G'], [0, 55, 39, 8, 'X'], [0, 55, 40, 4, 'P'], [0, 55, 54, 4, 'P'], [0, 55, 55, 4, 'U1'], [0, 55, 94, 4, 'U1'], [0, 55, 95, 8, 'U2'], [0, 55, 135, 12, 'U3']],
|
||||
'§7.2.1 常规 50 分坐庄': [[0, 50, 0, 18, 'G'], [0, 50, 39, 12, 'X'], [0, 50, 40, 6, 'P'], [0, 50, 49, 6, 'P'], [0, 50, 50, 6, 'U1'], [0, 50, 89, 6, 'U1'], [0, 50, 90, 12, 'U2'], [0, 50, 130, 18, 'U3']],
|
||||
'§7.2.1 常规 45 分坐庄': [[0, 45, 0, 18, 'G'], [0, 45, 39, 12, 'X'], [0, 45, 40, 6, 'P'], [0, 45, 44, 6, 'P'], [0, 45, 45, 6, 'U1'], [0, 45, 84, 6, 'U1'], [0, 45, 85, 12, 'U2'], [0, 45, 125, 18, 'U3']],
|
||||
'§7.2.1 常规 40 分及以下(无过庄档)': [[0, 40, 0, 18, 'G'], [0, 40, 39, 12, 'X'], [0, 40, 40, 6, 'U1'], [0, 40, 79, 6, 'U1'], [0, 40, 80, 12, 'U2'], [0, 10, 0, 18, 'G'], [0, 10, 5, 12, 'X'], [0, 10, 9, 12, 'X'], [0, 10, 10, 6, 'U1'], [0, 10, 49, 6, 'U1'], [0, 10, 50, 12, 'U2']],
|
||||
'§7.2.1 常规 5 分坐庄(无小光)': [[0, 5, 0, 18, 'G'], [0, 5, 5, 6, 'U1'], [0, 5, 44, 6, 'U1'], [0, 5, 45, 12, 'U2'], [0, 5, 85, 18, 'U3']],
|
||||
'§7.2.1 70 分坐庄·打牌': [[0, 70, 0, 6, 'G'], [0, 70, 39, 4, 'X'], [0, 70, 40, 2, 'P'], [0, 70, 69, 2, 'P'], [0, 70, 70, 2, 'U1'], [0, 70, 109, 2, 'U1'], [0, 70, 110, 4, 'U2'], [0, 70, 150, 6, 'U3'], [1, 70, 0, 6, 'G'], [1, 70, 70, 2, 'U1']],
|
||||
'§7.3.3 爬坡 40 分坐庄(Q=20)': [[1, 40, 0, 24, 'G'], [1, 40, 1, 16, 'X'], [1, 40, 19, 16, 'X'], [1, 40, 20, 8, 'P'], [1, 40, 39, 8, 'P'], [1, 40, 40, 8, 'U1'], [1, 40, 59, 8, 'U1'], [1, 40, 60, 16, 'U2'], [1, 40, 79, 16, 'U2'], [1, 40, 80, 24, 'U3']],
|
||||
'§7.3.3 爬坡 35 分坐庄(Q=20)': [[1, 35, 0, 27, 'G'], [1, 35, 19, 18, 'X'], [1, 35, 20, 9, 'P'], [1, 35, 34, 9, 'P'], [1, 35, 35, 9, 'U1'], [1, 35, 54, 9, 'U1'], [1, 35, 55, 18, 'U2'], [1, 35, 75, 27, 'U3']],
|
||||
'§7.3.3 爬坡 30 分坐庄(Q=15)': [[1, 30, 0, 30, 'G'], [1, 30, 14, 20, 'X'], [1, 30, 15, 10, 'P'], [1, 30, 29, 10, 'P'], [1, 30, 30, 10, 'U1'], [1, 30, 44, 10, 'U1'], [1, 30, 45, 20, 'U2'], [1, 30, 60, 30, 'U3']],
|
||||
'§7.3.3 爬坡 25 分坐庄(Q=15)': [[1, 25, 0, 33, 'G'], [1, 25, 14, 22, 'X'], [1, 25, 15, 11, 'P'], [1, 25, 24, 11, 'P'], [1, 25, 25, 11, 'U1'], [1, 25, 39, 11, 'U1'], [1, 25, 40, 22, 'U2'], [1, 25, 55, 33, 'U3']],
|
||||
'§7.3.3 爬坡 20 分坐庄(Q=10)': [[1, 20, 0, 36, 'G'], [1, 20, 9, 24, 'X'], [1, 20, 10, 12, 'P'], [1, 20, 19, 12, 'P'], [1, 20, 20, 12, 'U1'], [1, 20, 29, 12, 'U1'], [1, 20, 30, 24, 'U2'], [1, 20, 40, 36, 'U3']],
|
||||
'§7.3.3 爬坡 15 分坐庄(Q=10)': [[1, 15, 0, 39, 'G'], [1, 15, 9, 26, 'X'], [1, 15, 10, 13, 'P'], [1, 15, 14, 13, 'P'], [1, 15, 15, 13, 'U1'], [1, 15, 24, 13, 'U1'], [1, 15, 25, 26, 'U2'], [1, 15, 35, 39, 'U3']],
|
||||
'§7.3.3 爬坡 10 分坐庄(Q=5,无小光)': [[1, 10, 0, 42, 'G'], [1, 10, 5, 14, 'P'], [1, 10, 9, 14, 'P'], [1, 10, 10, 14, 'U1'], [1, 10, 14, 14, 'U1'], [1, 10, 15, 28, 'U2'], [1, 10, 20, 42, 'U3']],
|
||||
'§7.3.3 爬坡 5 分坐庄(无小光、无过庄)': [[1, 5, 0, 45, 'G'], [1, 5, 5, 15, 'U1'], [1, 5, 9, 15, 'U1'], [1, 5, 10, 30, 'U2'], [1, 5, 15, 45, 'U3']],
|
||||
'§7.3.1/§7.3.3 爬坡 ≥45 档(45 为爬坡独有)': [[1, 45, 0, 21, 'G'], [1, 45, 39, 14, 'X'], [1, 45, 40, 7, 'P'], [1, 45, 44, 7, 'P'], [1, 45, 45, 7, 'U1'], [1, 45, 85, 14, 'U2'], [1, 65, 0, 6, 'G'], [1, 60, 0, 9, 'G'], [1, 55, 0, 12, 'G'], [1, 50, 0, 18, 'G']]
|
||||
};
|
||||
let cellCount = 0;
|
||||
for (const name in TABLES) {
|
||||
const bad = [];
|
||||
for (const [c, call, grade, expX, expKind] of TABLES[name]) {
|
||||
cellCount++;
|
||||
const base = A.get_base_bycall(call, !!c), q = A.get_qvalue(call, !!c);
|
||||
const up = A.get_upgrade(call, grade, q);
|
||||
const kind = up === 3 ? 'G' : up === 2 ? 'X' : up === 1 ? 'P' : ('U' + (-up));
|
||||
const X = base * Math.abs(up);
|
||||
if (X !== expX || kind !== expKind) bad.push(['grade=' + grade, X + '/' + kind, expX + '/' + expKind]);
|
||||
}
|
||||
t.eq(name + ' 逐格失配', bad, []);
|
||||
}
|
||||
t.eq('design 判定表抽查格数', cellCount, 141);
|
||||
|
||||
// ============ 投降 / 解散:不走 7.1~7.3 模型(§7.0 例外、§12.2)============
|
||||
// 数值由 get_paiju_account 直接固定(投降 base=1、解散 0),此处只钉住 get_base_bycall 对 70 分的取值
|
||||
t.eq('§7.1 70 分打牌基础子数 2(两种模式一致)', [A.get_base_bycall(70, false), A.get_base_bycall(70, true)], [2, 2]);
|
||||
t.eq('§7.1 无叫分 → 0', [A.get_base_bycall(0, false), A.get_base_bycall(0, true)], [0, 0]);
|
||||
|
||||
process.exit(t.done('calc') ? 0 : 1);
|
||||
@@ -0,0 +1,236 @@
|
||||
// §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);
|
||||
Reference in New Issue
Block a user