二七王修复:拖拉机跟牌降级递归 else 分支欠约束(放行非法跟牌)

get_followcard 降规格递归中,当某级"等长短拖拉机候选总对数 > 剩余需求"
时(class.arith.js else 分支),因 cantype 单一结构模型无法表达"任选其一
成拖拉机、其余自由",直接返回宽松的"任意 N 对",导致跟牌方明明有 2 连对
却被允许只出三个孤立对子(如首家 3 连对、跟牌方 AA KK JJ TT 88 66 时打
AA JJ 88),违反 design §5.2"能凑多长拖拉机就必须先凑多长"。

方案 2(根治,不引入过约束):在 can_followcard 结构校验后新增"最大拖拉机
覆盖度"闸门 follow_tractor_cover_ok——
- tractor_run_lengths:把同花色对子按相邻拆成连对 run 长度表;
- max_tractor_profile:贪心最长优先取 K 对,得字典序最大规格档案 best;
- profile_less:跟牌自身档案 got 不得字典序劣于 best,否则拒。
仅在"跟牌整手=首家花色对子且该花色牌≥首家张数"时生效,垫/混合/对子不够
等一律放行。can_followcard 是唯一出牌闸门(paiju.js:605),覆盖人工+托管。
get_followcard else 分支加注释指明由该闸门兜底。

回归测试:test_follow.js +5 条(303/304 三组 2 连对场景,含 else 欠约束
回归:出孤立对→拒、出尽量长拖→合法)。全套 259 项断言通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 07:18:42 +08:00
co-authored by Claude Opus 4.8
parent cab66bea01
commit 21e76b976a
2 changed files with 103 additions and 1 deletions
+91 -1
View File
@@ -654,6 +654,84 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
return can;
},
//一组同花色牌里,成对部分按相邻拆成的连对 run 长度列表(单张忽略),降序
//(run 长度=该段连续对子的对数:孤立对子=1、两连对=2、三连对=3……)
tractor_run_lengths: function(mainflower, cards){
var comps = cls_youle_erqiwang_arith.decompose_trump(mainflower, cards);
var runs = [];
for (var i = 0; i < comps.length; i++){
if (comps[i].type == "tractor"){
runs.push(comps[i].len);
} else if (comps[i].type == "pair"){
runs.push(1);
}
//single 忽略:跟拖拉机时零散单张不参与对子覆盖度
}
runs.sort(function(a, b){ return b - a; });
return runs;
},
//给定可用连对 run 长度表与要取的对数 k,贪心"最长优先"得到字典序最大的规格档案
//(每次从最长 run 取 min(run, 剩余对数),即"能凑多长的拖拉机就先凑多长")
max_tractor_profile: function(runlens, k){
var avail = runlens.concat();
avail.sort(function(a, b){ return b - a; });
var profile = [];
var budget = k;
var idx = 0;
while (budget > 0 && idx < avail.length){
var take = avail[idx];
if (take > budget){ take = budget; }
profile.push(take);
budget = budget - take;
idx++;
}
return profile; //取用顺序已从大到小
},
//字典序比较:档案 a 是否严格劣于(小于)档案 b
profile_less: function(a, b){
var n = a.length < b.length ? a.length : b.length;
for (var i = 0; i < n; i++){
if (a[i] < b[i]){ return true; }
if (a[i] > b[i]){ return false; }
}
//前缀相同,较短者小
return a.length < b.length;
},
//§5.2 拖拉机跟牌"最大拖拉机覆盖度"校验(修复 get_followcard 降级递归 else 分支的欠约束):
//首家出拖拉机、且跟牌方该花色牌足够(跟牌须整手同花色对子)时,跟出的对子按相邻拆成拖拉机后的
//"规格档案"必须达到该手同花色对子在相同对数下可达的最优(最长拖拉机优先,逐档字典序比较),
//否则等于"放着更长的拖拉机不出、只出零散对子",不合法。
//返回 true 表示覆盖度达标或本情形不适用(放行,交由其他校验);false 表示不达标(拒绝)。
follow_tractor_cover_ok: function(mainflower, inhandcards, followcards, startcount, startflower){
//跟牌必须全是 startflower 花色(主牌算主花色;副7/副2 归主,不算副花色)
var _follow = cls_youle_erqiwang_arith.order_cards(mainflower, followcards.concat());
for (var i = 0; i < _follow.length; i++){
var codef = cls_youle_erqiwang_arith.id_to_code(mainflower, _follow[i]);
var flf = (codef > 1000) ? mainflower : parseInt(codef / 100);
if (flf != startflower){ return true; } //含非同花色(垫/混合),不适用
}
//跟牌必须整手成对
var followpairs = cls_youle_erqiwang_arith.get_pairlist(mainflower, _follow);
if (followpairs.length * 2 != _follow.length){ return true; } //非全对子(对子不够+补单张等),不适用
//跟牌方手中该花色的牌
var suit = [];
for (var h = 0; h < inhandcards.length; h++){
var codeh = cls_youle_erqiwang_arith.id_to_code(mainflower, inhandcards[h]);
var flh = (codeh > 1000) ? mainflower : parseInt(codeh / 100);
if (flh == startflower){ suit.push(inhandcards[h]); }
}
if (suit.length < startcount){ return true; } //该花色不够,允许混合/垫,本校验不适用
//该花色可用对子的连对 run,与跟牌自身的 run
var availruns = cls_youle_erqiwang_arith.tractor_run_lengths(mainflower, suit);
var best = cls_youle_erqiwang_arith.max_tractor_profile(availruns, startcount / 2);
var got = cls_youle_erqiwang_arith.tractor_run_lengths(mainflower, _follow);
//跟牌自身档案不得字典序劣于最优档案
return !cls_youle_erqiwang_arith.profile_less(got, best);
},
//根据第一个玩家的出牌获取后面玩家必出的和可出的牌
get_followcard: function(mainflower, inhandcards, startcount, startflower, startcardtype){
/*
@@ -902,9 +980,12 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
i++;
}
}
tlj_type = (tlj_type - tlj_find) + 300;
tlj_type = (tlj_type - tlj_find) + 300;
continue;
} else {
//候选等长拖拉机比剩余需求多(玩家可自选出哪一组):
//此处 cantype 无法表达"任选其一成拖拉机、其余自由",故返回宽松的"任意 N 对",
//由 can_followcard 的 follow_tractor_cover_ok 覆盖度校验兜底拒绝零散对子。
return re;
}
}
@@ -1020,6 +1101,15 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
}
}
//§5.2 拖拉机跟牌最大覆盖度校验:修复 get_followcard 降级递归 else 分支的欠约束
//(同花色对子足够、无同长拖拉机、又有多组等长短拖拉机时,必须尽量长地出拖拉机,不得只出零散对子)
if (startcardtype > 300 && startcardtype < 400){
if (!cls_youle_erqiwang_arith.follow_tractor_cover_ok(mainflower, inhandcards, followcards, startcount, startflower)){
do_returnfalse();
return can;
}
}
/////////// 可以跟牌 ///////////
//根据跟牌情况获取是否有相同花色的牌,是否有对子
//如果第一张牌的花色与第一家的出牌花色不一样则认为没有了相同花色的牌
+12
View File
@@ -70,6 +70,18 @@ t.eq('拖 3连对·有3连拖必出', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2),
t.eq('拖 3连对·富余对子中出3连拖', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), J(1), J(2)], 6, 2, 303)), true);
t.eq('拖 3连对·拆散3连拖(改出99)→拒', res(f([K(1), K(2), Q(1), Q(2), J(1), J(2), N9(1), N9(2)], [K(1), K(2), Q(1), Q(2), N9(1), N9(2)], 6, 2, 303)), false);
// —— 最大覆盖度校验:修复 get_followcard 降级递归 else 分支欠约束 ——
// 跟牌方该副花色 = AA KK JJ TT 88 66(三组互不相邻的2连对:{A,K} {J,T} {8,6})
const sA = d => id(d, 2, 1); // 副A(flower2, number1;非固定主)
const H3 = [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2), N8(1), N8(2), N6(1), N6(2)];
// 303:无3连拖、有多组2连拖 → 必出1个2连拖+1散对(档案[2,1]);出三孤立对(档案[1,1,1])应拒
t.eq('拖 303·三组2连·出2连+散(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), N8(1), N8(2)], 6, 2, 303)), true);
t.eq('拖 303·三组2连·出三孤立对AA/JJ/88→拒(else回归)', res(f(H3, [sA(1), sA(2), J(1), J(2), N8(1), N8(2)], 6, 2, 303)), false);
t.eq('拖 303·三组2连·另组KK/TT/66孤立→拒', res(f(H3, [K(1), K(2), T(1), T(2), N6(1), N6(2)], 6, 2, 303)), false);
// 304:无4连拖、有三组2连拖 → 必出两个2连拖(档案[2,2]);出1个2连+两散对(档案[2,1,1])应拒
t.eq('拖 304·三组2连·出两2连拖(合法)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), T(1), T(2)], 8, 2, 304)), true);
t.eq('拖 304·三组2连·出1个2连+两散对→拒(else回归)', res(f(H3, [sA(1), sA(2), K(1), K(2), J(1), J(2), N8(1), N8(2)], 8, 2, 304)), false);
// ===================== 手牌数 == 首出张数:全部必出 =====================
t.eq('全出 手牌恰1张(单张首出)', res(f([oth(9)], [oth(9)], 1, 2, 101)), true);
t.eq('全出 手牌恰2张(对子首出)', res(f([oth(9), oth(8)], [oth(9), oth(8)], 2, 2, 201)), true);