把第五轮复验用的取证探针固化入库。两者都不枚举「我想到的场景」,而是把 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>
103 lines
8.2 KiB
JavaScript
103 lines
8.2 KiB
JavaScript
// §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);
|