diff --git a/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md b/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md index e580b0d..0b28a25 100644 --- a/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md +++ b/server/games/erqiwang/docs/compliance/01-design合规逐节核对.md @@ -185,7 +185,11 @@ F1~F4 修复后,对 **design.md 维度**又做了一次独立取证复核。 > 一句话结论:**就 design.md 而言,服务端实现完整且准确**。本轮两次「疑似失配」(甩错最小张 170 例、闲家赢轮捡分 15 vs 10)经查**都是探针写错**,代码是对的;已在上表注明,后续核对不要再把它们当缺陷重提。 > -> 取证脚本为一次性探针,未入库(随机差分与穷举耗时较长,不适合放进每次都跑的 `test/run.js`);如需固化,优先固化 §7 全量对拍(确定性)与 §5.2 穷举差分(差分比对、不抖动)。 +> **已固化两组差分测试入库**(其余取证为一次性探针):`test/test_calc.js`(§7 全量对拍,7308 格 + design 表 141 格)与 `test/test_followdiff.js`(§5.2 穷举差分,约 11 万组),全套单测由 401 项增至 **462 项**,总耗时仍 < 1.5s。 +> +> 两组都做过**变异检验**:关掉 `follow_tractor_cover_ok` → `test_followdiff` 三种拖拉机首出全红;大光倍率 3→4 / 常规 55 档 base 4→5 / 爬坡 40·35 档 Q 20→40 → `test_calc` 精确指出档位与 grade。 +> +> ⚠️ 固化过程中的一个教训(改这两个文件时别踩回去):**差分测试的强度取决于造牌器,不取决于对拍逻辑**。第一版用纯随机抽牌,跑 7.8 万组全绿,但关掉 `follow_tractor_cover_ok` **依然全绿**——因为「同花色三组互不相邻的两连对」(12 张)这种唯一能区分"只出零散对子"与"先凑最长拖拉机"的结构,随机抽根本抽不出来(少于 3 组时任取 3 对必含相邻对,测不出差别)。补上确定性的 `structHands()` 后才转红。同理,掺入的其他花色一度把这手撑到 15 张、超过枚举上限被**静默跳过**,表面照常全绿。**"跑了很多组且全绿"不等于"测到了",新增差分测试必须做变异检验。** --- diff --git a/server/games/erqiwang/test/README.md b/server/games/erqiwang/test/README.md index f6e1c54..4a92430 100644 --- a/server/games/erqiwang/test/README.md +++ b/server/games/erqiwang/test/README.md @@ -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 限制);**正式代码不得为测试放宽/加后门**。 diff --git a/server/games/erqiwang/test/test_calc.js b/server/games/erqiwang/test/test_calc.js new file mode 100644 index 0000000..027b123 --- /dev/null +++ b/server/games/erqiwang/test/test_calc.js @@ -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); diff --git a/server/games/erqiwang/test/test_followdiff.js b/server/games/erqiwang/test/test_followdiff.js new file mode 100644 index 0000000..cbe4883 --- /dev/null +++ b/server/games/erqiwang/test/test_followdiff.js @@ -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);