Files
erqiwang_youle/server/games/erqiwang/test/test_calc.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

103 lines
8.2 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.
// §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);