二七王:实现 §5.4.4 强制跟牌分量拆解

原缺陷:混合甩牌在 can_playcard 牌型计算中被塌缩为扁平"多单张/多对",
跟牌只强制"同花色凑张数",未强制"拖拉机→对子→单张"的分量优先级。

修复:
- 合法甩牌时 can_playcard 记录分量需求 shuai_demand{tractors,pairs,singles},
  do_playcard 存入 playproc、new_playround 复位
- 新增 arith.flush_follow_ok:在"同花色凑张数、主牌最大化"之上追加逐分量强制匹配
  ——能凑同长主拖必凑、能凑主对必凑(不足则退化为对子/单张),不得拆散去垫
- 新增 arith.remove_pairs 辅助
- 跟牌时 do_playcard 在 can_followcard 通过后再过 flush_follow_ok

不影响捡分/输赢(合法甩牌仍恒不可反超),仅补齐闲家被迫垫牌的结构约束。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 23:20:09 +08:00
co-authored by Claude Opus 4.8
parent 592b51fff4
commit 257615aed9
2 changed files with 113 additions and 1 deletions
+101
View File
@@ -374,6 +374,93 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
return false;
},
//从对子列表中移除指定拖拉机(flat牌id数组)用到的对子
remove_pairs: function(pairlist, tractorcards){
var result = [];
for (var i = 0; i < pairlist.length; i++){
var inT = false;
for (var j = 0; j < tractorcards.length; j++){
if (tractorcards[j] == pairlist[i][0]){
inT = true;
break;
}
}
if (!inT){
result.push(pairlist[i]);
}
}
return result;
},
//跟甩牌的分量拆解校验(design §5.4.4)
//inhandcards: 跟牌方出牌前的全部手牌;followcards: 提交的跟牌;demand: {tractors:[连对数...], pairs, singles}
//在"同花色凑张数、主牌最大化"之上,追加"拖拉机→对子→单张"的强制分量优先级:
//能凑同长主拖就必须凑、能凑主对就必须凑,不得拆散去垫。返回 true 表示合规。
flush_follow_ok: function(mainflower, inhandcards, followcards, demand){
var isTrump = function(cid){
return cls_youle_erqiwang_arith.id_to_code(mainflower, cid) >= 1000;
}
//手牌与跟牌里的主牌
var heldTrump = [];
var playedTrump = [];
for (var i = 0; i < inhandcards.length; i++){
if (isTrump(inhandcards[i])){ heldTrump.push(inhandcards[i]); }
}
for (var i = 0; i < followcards.length; i++){
if (isTrump(followcards[i])){ playedTrump.push(followcards[i]); }
}
heldTrump = cls_youle_erqiwang_arith.order_cards(mainflower, heldTrump);
playedTrump = cls_youle_erqiwang_arith.order_cards(mainflower, playedTrump);
//甩牌总张数
var total = demand.singles + demand.pairs * 2;
for (var t = 0; t < demand.tractors.length; t++){
total = total + demand.tractors[t] * 2;
}
//主牌最大化:手上主牌够则必须全打主牌,不够则全部主牌都要打出
var needTrump = (heldTrump.length >= total) ? total : heldTrump.length;
if (playedTrump.length != needTrump){
return false;
}
//按 拖拉机(长→短) → 对子 的优先级,算出跟牌方"必须打出"的拖拉机与对子数
var heldPairs = cls_youle_erqiwang_arith.get_pairlist(mainflower, heldTrump);
var _tractors = demand.tractors.concat();
_tractors.sort(function(a, b){ return b - a; }); //降序
var reqTractors = [];
var workPairs = heldPairs.concat();
var unmatchedTractorPairs = 0;
for (var t = 0; t < _tractors.length; t++){
var N = _tractors[t];
var tl = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, workPairs, 300 + N);
if (tl.length > 0){
reqTractors.push(N);
workPairs = cls_youle_erqiwang_arith.remove_pairs(workPairs, tl[0]);
} else {
//凑不成同长拖拉机,该拖拉机分量退化为对子需求
unmatchedTractorPairs = unmatchedTractorPairs + N;
}
}
//对子需求 = 对子分量 + 退化的拖拉机分量;受限于剩余可凑对子数
var wantedPairs = demand.pairs + unmatchedTractorPairs;
var reqPairs = (workPairs.length < wantedPairs) ? workPairs.length : wantedPairs;
//校验跟牌里的主牌是否包含这些必须的拖拉机与对子
var playedPairs = cls_youle_erqiwang_arith.get_pairlist(mainflower, playedTrump);
for (var r = 0; r < reqTractors.length; r++){
var ptl = cls_youle_erqiwang_arith.get_tuolaji_list(mainflower, playedPairs, 300 + reqTractors[r]);
if (ptl.length == 0){
return false; //必须打出的同长主拖拉机没打
}
playedPairs = cls_youle_erqiwang_arith.remove_pairs(playedPairs, ptl[0]);
}
if (playedPairs.length < reqPairs){
return false; //必须打出的主对子没凑够
}
return true;
},
//第一个位置是否可出牌
can_playcard: function(mainflower, cards, selfseat, seatlist, opp_zhulist){
/*
@@ -548,6 +635,20 @@ var cls_youle_erqiwang_arith = cls_youle_erqiwang_arith || {
can.smallest = cards[cards.length - 1]; //cards 已从大到小排序,末位最小
return can;
}
//合法甩牌:记录分量需求,供跟牌逐分量强制匹配(§5.4.4)
var _dTractors = [];
var _dPairs = 0;
var _dSingles = 0;
for (var ci2 = 0; ci2 < comps.length; ci2++){
if (comps[ci2].type == "tractor"){
_dTractors.push(comps[ci2].len);
} else if (comps[ci2].type == "pair"){
_dPairs = _dPairs + 1;
} else {
_dSingles = _dSingles + 1;
}
}
can.shuai_demand = { tractors: _dTractors, pairs: _dPairs, singles: _dSingles };
}
return can;
+12 -1
View File
@@ -485,7 +485,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
o_paiju.playproc.maxseat = -1; //谁的牌最大,位置
o_paiju.playproc.maxcard = -1; //满足第一个玩家出牌的牌型最大牌
o_paiju.playproc.cards = []; //本轮各位置出的牌,数组与位置序号对应
o_paiju.playproc.cards.length= 3;
o_paiju.playproc.cards.length= 3;
o_paiju.playproc.shuai_demand= null; //首家甩牌的分量需求(供跟牌逐分量匹配),非甩牌为null
},
//在牌列表中获取分牌和分值
@@ -577,6 +578,8 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
o_paiju.playproc.starttype = can.cardtype;
o_paiju.playproc.maxseat = o_paiju.playproc.currseat;
o_paiju.playproc.maxcard = can.cardvalue;
//合法甩牌的分量需求,供跟牌逐分量强制匹配(§5.4.4);甩错退化为单张则为 null
o_paiju.playproc.shuai_demand = can.shuai_demand ? can.shuai_demand : null;
o_paiju.playproc.currseat = nextseat_1;
//返回结果
var re1 = {};
@@ -598,6 +601,14 @@ var cls_youle_erqiwang_paiju = cls_youle_erqiwang_paiju || {
re.result = false;
return re;
}
//首家是甩牌时,追加"拖拉机→对子→单张"的强制分量匹配校验(§5.4.4)
if (o_paiju.playproc.shuai_demand){
if (!cls_youle_erqiwang_arith.flush_follow_ok(o_paiju.flower, cardsinhand, cards, o_paiju.playproc.shuai_demand)){
var reF = {};
reF.result = false;
return reF;
}
}
//与之前的出牌比较大小
if (can.cardvalue > o_paiju.playproc.maxcard){