diff --git a/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/RobotConnectionManager.java b/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/RobotConnectionManager.java index 412205a..4009d40 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/RobotConnectionManager.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/RobotConnectionManager.java @@ -412,7 +412,7 @@ public class RobotConnectionManager { robotUser.setStatus(ROBOTEventType.ROBOT_INTOROOM_WORKING); //初始化收手牌 String key = robotId+""; - log.info("key+++++++++++++++++++++++++++++++++++" + key); + log.info("新的一局key+++++++++++++++++++++++++++++++++++" + key); if (jedis2.hget("{robortInfo}:" + key, "circleId") != null && jedis2.hget("{robortInfo}:" + key, "pid") != null) { String circleId = jedis2.hget("{robortInfo}:" + key, "circleId"); log.info("circleId +++++++++++++++++++++++++"+circleId); @@ -431,6 +431,7 @@ public class RobotConnectionManager { } //出牌广播 else if ("812".equalsIgnoreCase(command)) { + log.info("[协议812] 出牌广播 param:{}", param); ITArray outcard_map = param.getTArray("outcard_map"); ITArray opchicards = param.getTArray("opchicards"); ITArray oppengcards = param.getTArray("oppengcards"); @@ -549,22 +550,40 @@ public class RobotConnectionManager { Map> currentPlayerzisMap = getPlayerzisMap(connecId); huNanChangSha.outCard(client, currentPlayerOutcardsMap, currentPlayerchisMap, currentPlayerpengsMap, currentPlayermingsMap, currentPlayerzisMap); - //处理完协议后保存到Redis - HeChi currentInstance = huNanChangShaInstances.get(connecId); - currentInstance.saveToRedis(connecId); + //处理完协议后保存到Redis(加try-catch避免Redis异常导致机器人卡死) + try { + HeChi currentInstance = huNanChangShaInstances.get(connecId); + if (currentInstance != null) { + currentInstance.saveToRedis(connecId); + } + } catch (Exception redisEx) { + log.warn("813处理完成后保存Redis失败(不影响出牌): {}", redisEx.getMessage()); + } //收到补杠协议 }else if("838".equalsIgnoreCase(command)) { huNanChangSha.bugang(param,robotUser,client); - //处理完协议后保存到Redis - HeChi currentInstance = huNanChangShaInstances.get(connecId); - currentInstance.saveToRedis(connecId); + //处理完协议后保存到Redis(加try-catch避免Redis异常导致机器人卡死) + try { + HeChi currentInstance = huNanChangShaInstances.get(connecId); + if (currentInstance != null) { + currentInstance.saveToRedis(connecId); + } + } catch (Exception redisEx) { + log.warn("838处理完成后保存Redis失败(不影响出牌): {}", redisEx.getMessage()); + } } //放招提示 else if ("814".equalsIgnoreCase(command)) { huNanChangSha.actionCard(param, client); - //处理完协议后保存到Redis - HeChi currentInstance = huNanChangShaInstances.get(connecId); - currentInstance.saveToRedis(connecId); + //处理完协议后保存到Redis(加try-catch避免Redis异常导致机器人卡死) + try { + HeChi currentInstance = huNanChangShaInstances.get(connecId); + if (currentInstance != null) { + currentInstance.saveToRedis(connecId); + } + } catch (Exception redisEx) { + log.warn("814处理完成后保存Redis失败(不影响出牌): {}", redisEx.getMessage()); + } } //2026.02.03修改 玩家加入房间 else if ("2001".equalsIgnoreCase(command)) { @@ -748,6 +767,7 @@ public class RobotConnectionManager { //服务器通知客户端有玩家执行了操作 else if ("815".equalsIgnoreCase(command)) { huNanChangSha.shanchuchuguopai(param); + log.info("[协议815] 玩家操作通知 - param:{}", param); //处理完协议后保存到Redis HeChi currentInstance = huNanChangShaInstances.get(connecId); currentInstance.saveToRedis(connecId); @@ -783,8 +803,15 @@ public class RobotConnectionManager { }); } + //服务器返回的错误码(如3005) + else if ("3005".equalsIgnoreCase(command)) { + System.out.println("[3005错误] 服务器返回错误码3005! param:" + param); + log.error("[协议3005] 服务器返回错误! param:{}", param); + } } catch (Exception e) { - log.error("处理接收到的游戏协议"); + System.out.println("【ERROR】处理游戏协议异常! 类型=" + e.getClass().getName() + + ", 信息=" + e.getMessage() + ", 协议号=" + command); + e.printStackTrace(System.out); } finally { jedis0.close(); jedis2.close(); diff --git a/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/handler/HeChi.java b/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/handler/HeChi.java index f2ca488..02a8866 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/handler/HeChi.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/robot/mj/handler/HeChi.java @@ -28,6 +28,30 @@ public class HeChi { public static boolean isTinPeng = false; private final List changShaCardInhandgang = new ArrayList<>(); + /** + * 牌号转可读字符串(河池麻将专用) + * 101-109=一万~九万, 201-209=一条~九条, 301-309=一筒~九筒 + * 401=东风,402=南风,403=西风,404=北风, 501=红中,502=发财,503=白板 + */ + private static String cardToString(int card) { + int suit = card / 100; + int value = card % 100; + String[] suitNames = {"", "万", "筒", "条", "风", "箭"}; + String[] valueNames = {"", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; + switch (suit) { + case 1: case 2: case 3: + return valueNames[value] + suitNames[suit] + "(" + card + ")"; + case 4: + String[] windNames = {"", "东风", "南风", "西风", "北风"}; + return windNames[value] + "(" + card + ")"; + case 5: + String[] arrowNames = {"", "红中", "发财", "白板"}; + return arrowNames[value] + "(" + card + ")"; + default: + return "未知(" + card + ")"; + } + } + //河池麻将出过的牌 private final List changShachuguopai = new ArrayList<>(); @@ -55,6 +79,25 @@ public class HeChi { private Integer lastChowCard2 = null; private Integer lastChowCard3 = null; + /** + * 追踪最近一次612(吃碰杠)协议的实际发送时间戳 + * 用于outCard发送611前判断是否需要等待,避免611先于612到达服务器导致3005错误 + */ + private volatile long last612SendTime = 0; + + /** + * 检查出牌是否受河池麻将吃牌门子限制 + * 规则:如果吃的牌属于147/258/369组,同个门子(同花色)同组的牌不能打出 + * 147组: value%3==1 (1,4,7) + * 258组: value%3==2 (2,5,8) + * 369组: value%3==0 (3,6,9) + * 需要检查吃的三张牌,因为三张牌都可能触发限制 + */ + private boolean isChowMenziRestricted(int discardCard) { + // 【已禁用】河池麻将无147/258/369吃牌门子出牌限制规则 + return false; + } + //会话标识 public String session = ""; //访问令牌 @@ -264,6 +307,9 @@ public class HeChi { if (param == null) { return null; } + int card = param.getInt("card"); + int seat = param.getInt("seat"); + log.info("[812出牌广播] 座位{}打出牌:{}", seat, cardToString(card)); changShaCard = param.getInt("card"); } return null; @@ -281,22 +327,37 @@ public class HeChi { if (command.equalsIgnoreCase("819")) { ITObject param = message.param; if (param == null) { + System.out.println("[819摸牌-诊断] WARNING: param为空,无法获取摸牌数据!changSaCardInhand当前:" + changShaCardInhand); return null; } Jedis jedis222 = Redis.use("group1_db2").getJedis(); Integer seat1 = param.getInt("seat"); Integer seat2 = robotUser.getSeat(); - System.out.println("819机器人座位号 " + seat2); - System.out.println("819真人座位号 " + seat1); + int drawnCard = param.getInt("card"); + + // ====== 诊断日志(使用System.out确保可见) ====== + System.out.println("[819摸牌-诊断] 协议原始数据 - seat(协议):" + seat1 + ", seat(机器人):" + seat2 + + ", card(摸到的牌):" + drawnCard + "(" + cardToString(drawnCard) + ")"); + System.out.println("[819摸牌-诊断] 摸牌前手牌(" + changShaCardInhand.size() + "张):" + changShaCardInhand); + // ====== 结束诊断 ====== if (Objects.equals(seat1, seat2)) { - System.out.println("819能不能进来"); - int drawnCard = param.getInt("card"); + // 是机器人自己摸牌 + log.info("[819摸牌] 机器人座位{}摸到牌:{}", seat2, cardToString(drawnCard)); changShaSuanFaTest.drawnCards = drawnCard;//存储摸到的牌 changShaCardInhand.add(drawnCard); - log.info("param.getInt(player): {}", param.getInt("player")); - log.info("摸到的牌 +++++++++ : {}", drawnCard); + + // ====== 诊断日志 ====== + System.out.println("[819摸牌-诊断] ✓ 确认是机器人自己摸牌!添加后手牌(" + changShaCardInhand.size() + "张):" + changShaCardInhand); + // ====== 结束诊断 ====== + + log.info("[819摸牌] 摸牌后手牌:{} (含红中{}张)", changShaCardInhand, + changShaCardInhand.stream().filter(c -> c == 501).count()); + } else { + // 是其他玩家摸牌 + log.info("[819摸牌] 座位{}玩家摸牌(非机器人,机器人座位{})", seat1, seat2); + System.out.println("[819摸牌-诊断] ✗ 不是机器人摸牌,跳过。seat1=" + seat1 + ", seat2=" + seat2); } jedis222.close(); @@ -357,9 +418,11 @@ public class HeChi { /** * 延迟执行吃碰胡动作,增加自然的停顿效果 + * 修复:将本地手牌状态的修改延迟到发送成功后(避免3005手牌错误) * * @param client 客户端连接 - * @param params 动作参数 + * @param params 动作参数(包含_pendingType等内部字段用于状态修改) + * @param actionName 动作名称 */ private void delayedActionCard(TaurusClient client, ITObject params, String actionName) { //使用线程池执行延迟动作 @@ -370,7 +433,10 @@ public class HeChi { Thread.sleep(delaySeconds * 1000); client.send("612", params, response -> { - log.info("{}动作发送完成", actionName); + // 记录612实际发送时间,供outCard发送611时参考 + last612SendTime = System.currentTimeMillis(); + log.info("{}动作发送完成, 时间戳:{}", actionName, last612SendTime); + // 注意:状态修改已在actionCard()中立即执行,此处不再重复修改 }); } catch (Exception e) { log.error("执行{}动作时发生异常: {}", actionName, e.getMessage(), e); @@ -432,27 +498,71 @@ public class HeChi { * {"tip_list":[{"type":3,"id":1,"opcard":[206],"weight":3,"card":206}]} */ public String actionCard(ITObject param, TaurusClient client) { - System.out.println("814所有参数" + param); + // System.out.println("[814吃碰杠胡] 收到操作选项:" + param); //已精简 + try { ITArray tipList = param.getTArray("tip_list"); + + // 诊断:tipList是否为null + if (tipList == null) { + System.out.println("[814] ⚠️ tipList为null! param=" + param); + tipList = TArray.newInstance();//防止后续NPE + } + System.out.println("[814] tipList.size=" + tipList.size()); + ITObject params = TObject.newInstance(); int card = 0; - - //循环 List yupanhandcard = new ArrayList<>(); yupanhandcard.addAll(changShaCardInhand); //进行操作之前能否下听 + // 统计手牌中红中数量 + int actionHongZhongCount = 0; + List handWithoutHZ = new ArrayList<>(); + for (Integer c : changShaCardInhand) { + if (c == 501) { + actionHongZhongCount++; + } else { + handWithoutHZ.add(c); + } + } + // 修复日志:SLF4J无binding导致{}不被替换,改用System.out.println确保可看到值 + // System.out.println("[814操作前] 手牌:" + changShaCardInhand + ", 红中数量:" + actionHongZhongCount); //已精简 + List shifoutingpai = changShaSuanFaTest.handscardshifoutingpai(changShaCardInhand, chowGroup, pongGroup, gangdepai); boolean beforelisten = false;//记录操作之前的下听状态 - log.info("shifoutingpai{}", shifoutingpai); + // System.out.println("[814操作前] 听牌结果:" + shifoutingpai + ", 是否已听牌:" + (shifoutingpai.size() > 0)); //已精简 if (shifoutingpai.size() > 0) { beforelisten = true; //操作之前是听牌的 } - log.info("beforelisten{}", beforelisten); + // 兜底检测:如果handscardshifoutingpai没检测到听牌,再用WinCard直接检测手牌能否胡牌 + // 只要手牌加任何一张牌就能胡,说明已经在听牌状态 + if (!beforelisten && changShaCardInhand.size() == 13) { + boolean canWin = false; + List candidateCards = new ArrayList<>(); + for (int j = 101; j <= 109; j++) candidateCards.add(j); + for (int j = 201; j <= 209; j++) candidateCards.add(j); + for (int j = 301; j <= 309; j++) candidateCards.add(j); + for (int j = 401; j <= 404; j++) candidateCards.add(j); + for (int j = 502; j <= 503; j++) candidateCards.add(j); + for (Integer candidate : candidateCards) { + WinCard win = new WinCard(handWithoutHZ, candidate, actionHongZhongCount, 501); + if (win.tryWin()) { + canWin = true; + break; + } + } + if (canWin) { + beforelisten = true; + // System.out.println("[814兜底检测] handscardshifoutingpai未检测到听牌,但WinCard检测到手牌可胡 → 已听牌"); //已精简 + } + } + + System.out.println("beforelisten:" + beforelisten); //保留:关键状态标记 + //如果杠了之后还能继续听牌则杠 //优先处理胡牌 @@ -501,16 +611,22 @@ public class HeChi { params.putInt("qi", 0); params.putInt("id", 0); delayedActionCard(client, params, "不开杠"); - System.out.println("不开杠111"); return "不开杠"; } + // 明杠:立即修改本地状态确保后续出牌使用正确手牌 Util.removeCard(changShaCardInhand, card, 3); + + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + log.info("[状态同步] 明杠{}立即更新本地手牌:{}", cardToString(card), changShaCardInhand); + params.putString("session", session + "," + token); params.putInt("qi", 0); params.putInt("id", id); delayedActionCard(client, params, "开杠"); - System.out.println("开杠222"); return "开杠"; } else { params.putString("session", session + "," + token); @@ -542,7 +658,15 @@ public class HeChi { return "不开杠"; } + // 暗杠:立即修改本地状态确保后续出牌使用正确手牌 Util.removeCard(changShaCardInhand, card, 4); + + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + log.info("[状态同步] 暗杠{}立即更新本地手牌:{}", cardToString(card), changShaCardInhand); + params.putString("session", session + "," + token); params.putInt("qi", 0); params.putInt("id", id); @@ -577,7 +701,15 @@ public class HeChi { } + // 补杠:立即修改本地状态确保后续出牌使用正确手牌 Util.removeCard(changShaCardInhand, card, 1); + + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + gangdepai.add(card); + log.info("[状态同步] 补杠{}立即更新本地手牌:{}", cardToString(card), changShaCardInhand); + params.putString("session", session + "," + token); params.putInt("qi", 0); params.putInt("id", id); @@ -652,6 +784,7 @@ public class HeChi { sj.putInt("weight", weight); sj.putInt("type", type); sj.putTArray("opcard", opcard); + sj.putInt("card", card); // 必须存入card字段,否则后续getInt("card")会NPE idObject.put(id, sj); } //计算分数 @@ -673,37 +806,84 @@ public class HeChi { if (entry.getKey() == changeid) { tmp = entry.getValue(); log.debug("tmp ++++++++++= {}", tmp); - if (tmp.getInt("type") == 2) { - //碰 - ITArray outcards = tmp.getTArray("opcard"); - for (int i = 0; i < outcards.size(); i++) { - Util.removeCard(changShaCardInhand, outcards.getInt(0), 2); - } - pongGroup.add(outcards.getInt(0)); - pongGroup.add(outcards.getInt(0)); - pongGroup.add(outcards.getInt(0)); - - } else if (tmp.getInt("type") == 1) { - //吃 - ITArray outcards = tmp.getTArray("opcard"); - for (int i = 0; i < outcards.size(); i++) { - Util.removeCard(changShaCardInhand, outcards.getInt(i), 1); - } - log.debug("判断吃 +++++++++{}", card); - log.debug("判断吃 ========== {}", outcards); - chowGroup.add(card); - chowGroup.add(outcards.getInt(0)); - chowGroup.add(outcards.getInt(1)); - - - // 记录最后吃的三张牌(用于门子规则判断) - lastChowCard1 = card; - lastChowCard2 = outcards.getInt(0); - lastChowCard3 = outcards.getInt(1); - System.out.println("记录最后吃的三张牌(用于门子规则判断" + lastChowCard1 + lastChowCard2 + lastChowCard3); - } + + break; // 找到就跳出,避免不必要的遍历 } } + + // 防御性检查:如果changeid在idObject中找不到对应条目 + if (tmp == null) { + System.out.println("[814] ⚠️ changeid=" + changeid + " 在idObject中未找到! keys=" + idObject.keySet() + ", 兜底执行'过'"); + params.putString("session", session + "," + token); + params.putInt("qi", 0); + params.putInt("id", 0); + delayedActionCard(client, params, "changeid未找到-过"); + return null; + } + + // ===== 🚀 碰杠破坏性评估 ===== + int opType = tmp.getInt("type"); + int opCard = tmp.getInt("card"); + List beforeOpHand = new ArrayList<>(yupanhandcard); + + if (opType == 2 || opType == 3 || opType == 5) { + try { + double damage = ChangshaWinSplitCard.assessOperationDamage( + opType, + opCard, + beforeOpHand, + new ArrayList<>(), // 操作后的手牌稍后计算 + beforelisten, + false // 是否听牌稍后检测 + ); + + System.out.println("【碰杠破坏性评估】操作类型=" + opType + + ", 牌=" + cardToString(opCard) + + ", 破坏性评分=" + String.format("%.1f", damage) + + (damage > 10 ? " ⚠️ 破坏性较高!" : "")); + + if (damage > 20) { + // System.out.println("⚠️ 警告:此操作破坏性极高(" + damage + "),建议重新考虑!"); + } + } catch (Exception e) { + log.warn("破坏性评估异常: {}", e.getMessage()); + } + } + // ===== 结束破坏性评估 ===== + + if (tmp.getInt("type") == 2) { + //碰 - 立即修改本地状态确保后续出牌(813/611)使用正确的手牌 + ITArray outcards = tmp.getTArray("opcard"); + + // 立即执行状态修改 + int pengCard = outcards.getInt(0); + Util.removeCard(changShaCardInhand, pengCard, 3); + pongGroup.add(pengCard); + pongGroup.add(pengCard); + pongGroup.add(pengCard); + log.info("[状态同步] 碰牌{}立即更新本地手牌:{}", cardToString(pengCard), changShaCardInhand); + + } else if (tmp.getInt("type") == 1) { + //吃 - 立即修改本地状态确保后续出牌(813/611)使用正确的手牌 + ITArray outcards = tmp.getTArray("opcard"); + + // 立即执行状态修改 + Util.removeCard(changShaCardInhand, outcards.getInt(0), 1); + Util.removeCard(changShaCardInhand, outcards.getInt(1), 1); + + chowGroup.add(card); + chowGroup.add(outcards.getInt(0)); + chowGroup.add(outcards.getInt(1)); + log.info("[状态同步] 吃牌{}{}{}立即更新本地手牌:{}", + cardToString(card), cardToString(outcards.getInt(0)), cardToString(outcards.getInt(1)), + changShaCardInhand); + + // 记录最后吃的三张牌(用于门子规则判断) + lastChowCard1 = card; + lastChowCard2 = outcards.getInt(0); + lastChowCard3 = outcards.getInt(1); + } + params.putString("session", session + "," + token); params.putInt("qi", 0); params.putInt("id", changeid); @@ -800,6 +980,7 @@ public class HeChi { params.putInt("qi", 0); params.putInt("id", id); + // 补杠/开杠:立即修改本地状态确保后续出牌使用正确手牌 if (type == 3) { Util.removeCard(changShaCardInhand, card, 3); gangdepai.add(card); @@ -821,6 +1002,7 @@ public class HeChi { gangdepai.add(card); gangdepai.add(card); } + delayedActionCard(client, params, ""); return null; } @@ -830,6 +1012,7 @@ public class HeChi { params.putInt("qi", 0); params.putInt("id", id); + // 补杠/开杠:立即修改本地状态确保后续出牌使用正确手牌 if (type == 3) { Util.removeCard(changShaCardInhand, card, 3); gangdepai.add(card); @@ -863,6 +1046,22 @@ public class HeChi { delayedActionCard(client, params, "默认动作"); return null; + } catch (Exception e) { + // SLF4J NOP logger无法输出参数,用System.out.println确保异常可见 + System.out.println("[814] ⚠️ actionCard内部异常! 类型=" + e.getClass().getName() + ", 信息=" + e.getMessage()); + e.printStackTrace(System.out); + // 兜底:发送"过"/空操作避免机器人卡死 + try { + ITObject fallbackParams = TObject.newInstance(); + fallbackParams.putString("session", session + "," + token); + fallbackParams.putInt("qi", 0); + fallbackParams.putInt("id", 0); + delayedActionCard(client, fallbackParams, "异常兜底-过"); + } catch (Exception ex) { + System.out.println("[814] ⚠️ 兜底操作也失败: " + ex.getMessage()); + } + return null; + } } @@ -917,81 +1116,71 @@ public class HeChi { } - // 河池麻将出牌 - 先过滤掉红中(501),不让算法处理 + // 河池麻将出牌 - 先过滤掉红中(501),不让算法处理(红中通过hongZhongCount传入算法) List handCardsWithoutHongZhong = new ArrayList<>(); for (Integer card : changShaCardInhand) { if (card == 501) { - System.out.println("红中先过滤掉" + changShaCardInhand); + // 红中不参与算法处理,通过hongZhongCount传入 } else { handCardsWithoutHongZhong.add(card); } } - System.out.println("过滤掉红中的手牌" + handCardsWithoutHongZhong); + log.info("[出牌分析] 手牌(含红中):{}, 红中数量:{}, 过滤后手牌:{}", changShaCardInhand, hongZhongCount, handCardsWithoutHongZhong); - log.info("河池麻将出牌{}", changShaCardInhand); // 河池麻将出牌 String changShaOutCard = changShaSuanFaTest.outCardSuanFa(handCardsWithoutHongZhong, pongGroup, chowGroup, gangdepai, resultList,hongZhongCount); - //如果最后吃的牌是147,258,369,同个门子的牌不能打出 需要换张牌打 + //如果最后吃了牌,147/258/369同个门子(同花色)同组的牌不能打出,需要换张牌打 if (lastChowCard1 != null && lastChowCard2 != null && lastChowCard3 != null) { - boolean canOut = false; int outcard = Integer.parseInt(changShaOutCard); - if (lastChowCard1 - outcard == 3 && lastChowCard2 - outcard < 3 - && lastChowCard3 - outcard < 3) { - canOut = true; - } else if (outcard - lastChowCard1 == 3 && outcard - lastChowCard2 < 3 - && outcard - lastChowCard3 < 3) { - canOut = true; - } - if (canOut) { - System.out.println("如果最后吃的牌是147,258,369,同个门子的牌不能打出 需要换张牌打" + outcard); - System.out.println("lastChowCard1 " + lastChowCard1); - System.out.println("lastChowCard2 " + lastChowCard2); - System.out.println("lastChowCard3 " + lastChowCard3); + System.out.println("[门子规则检查] 原选牌:" + outcard + "(" + cardToString(outcard) + "), 吃的牌:[" + + lastChowCard1 + "," + lastChowCard2 + "," + lastChowCard3 + "]"); + if (isChowMenziRestricted(outcard)) { + System.out.println("门子规则限制:吃的牌[" + lastChowCard1 + "," + lastChowCard2 + "," + lastChowCard3 + "]同组同门子牌不能打出,原选牌:" + outcard); List tempHandCards = new ArrayList<>(); tempHandCards.addAll(handCardsWithoutHongZhong); - tempHandCards.remove(Integer.valueOf(outcard)); - System.out.println("完整手牌 changShaCardInhand" + changShaCardInhand); - System.out.println("从手牌中删除禁止打的牌后的手牌" + tempHandCards); - if (!tempHandCards.isEmpty()) { - System.out.println("进入重新计算"); + // 逐步移除被门子规则禁止的牌,重新选牌(增加最大重试次数防止死循环) + int maxRetries = tempHandCards.size(); // 最多尝试手牌张数次 + int retryCount = 0; + while (tempHandCards.size() > 1 && retryCount < maxRetries) { + retryCount++; + tempHandCards.remove(Integer.valueOf(outcard)); + System.out.println("移除被门子限制的牌:" + outcard + "(" + cardToString(outcard) + "),第" + retryCount + "次重试,剩余手牌:" + tempHandCards); changShaOutCard = changShaSuanFaTest.outCardSuanFa(tempHandCards, pongGroup, chowGroup, gangdepai, resultList,hongZhongCount); + + // 安全检查:outCardSuanFa返回空或无效 + if (changShaOutCard == null || changShaOutCard.isEmpty()) { + System.out.println("[门子规则] ⚠️ outCardSuanFa返回空! 强制使用剩余手牌第一张"); + if (!tempHandCards.isEmpty()) { + changShaOutCard = String.valueOf(tempHandCards.get(0)); + } + break; + } + int newOutcard = Integer.parseInt(changShaOutCard); - boolean newcanOut = false; - while (tempHandCards.size() > 1) { - if (lastChowCard1 - newOutcard == 3 && lastChowCard2 - newOutcard < 3 - && lastChowCard3 - newOutcard < 3) { - newcanOut = true; - } else if (newOutcard - lastChowCard1 == 3 && newOutcard - lastChowCard2 < 3 - && newOutcard - lastChowCard3 < 3) { - newcanOut = true; - } - if (newcanOut){ - System.out.println("新选的牌也被门子规则禁止,继续尝试" + newOutcard); - tempHandCards.remove(Integer.valueOf(newOutcard)); - changShaOutCard = changShaSuanFaTest.outCardSuanFa(tempHandCards, pongGroup, chowGroup, gangdepai, resultList,hongZhongCount); - newOutcard = Integer.parseInt(changShaOutCard); - } else { - changShaOutCard = String.valueOf(newOutcard); - break; - } + if (!isChowMenziRestricted(newOutcard)) { + System.out.println("找到不受门子限制的出牌:" + newOutcard + "(" + cardToString(newOutcard) + "),共重试" + retryCount + "次"); + break; + } else { + System.out.println("新选的牌" + newOutcard + "(" + cardToString(newOutcard) + ")也被门子规则禁止,继续尝试"); + outcard = newOutcard; } } - + + if (retryCount >= maxRetries) { + System.out.println("[门子规则] ⚠️ 达到最大重试次数" + maxRetries + "次! 强制使用当前候选牌:" + changShaOutCard); + } + } else { + System.out.println("[门子规则检查] 选牌" + outcard + "(" + cardToString(outcard) + ")不受门子限制,正常出牌"); } - -// if (canOut && owner.cardInhand.size() == 2) { -// owner.isFenghu = true; -// } else if (canOut) { -// ITObject reconParam = new TObject(); -// owner.sendEvent(Router.GAME_EVT__UPDATE_RECONECT, reconParam); -// return; -// -// } + + // 清理门子数据(无论是否触发限制都要清理) + lastChowCard1 = null; + lastChowCard2 = null; + lastChowCard3 = null; } - log.info("河池麻将出牌{}", changShaCardInhand); ITObject params = TObject.newInstance(); @@ -1020,19 +1209,39 @@ public class HeChi { changShaCardInhand.remove(Integer.valueOf(cardToOut)); log.info("打过后的手牌 +++ {}", changShaCardInhand); params.putString("session", session + "," + token); - System.out.println("河池麻将出牌参数 +" + params); + log.info("[出牌决定] 机器人打出:{} (手牌:{}, 已出牌:{})", + cardToString(cardToOut), changShaCardInhand, changShachuguopai); //清理最后一次吃的数据 lastChowCard1 = null; lastChowCard2 = null; lastChowCard3 = null; - //使用线程池替代CompletableFuture.runAsync + Thread.sleep + + //使用线程池发送出牌协议611 getBusinessThreadPool().execute(() -> { try { - int ot = new Random().nextInt(4); - Thread.sleep(ot * 1000); - client.send("611", params, response -> { + int ot = new Random().nextInt(4); // 基础随机延迟0~3秒 + // ===== 关键修复:检测是否有待确认的612(吃碰杠)协议 ===== + // 问题根因:actionCard()立即修改本地状态后,outCard基于新状态构建card_list + // 但如果612还没到达服务器,服务器用旧状态校验 → 3005错误 + long now = System.currentTimeMillis(); + long elapsedSince612 = now - last612SendTime; + // 如果5秒内有612发送记录,需要额外等待确保612先到达服务器 + int extraDelayMs = 0; + if (last612SendTime > 0 && elapsedSince612 < 5000) { + // 612可能在路上,额外等待1~2秒让网络传输完成 + extraDelayMs = 1000 + new Random().nextInt(1000); + log.info("[611顺序保护] 检测到{}ms前有612发送,额外延迟{}ms确保到达顺序", + elapsedSince612, extraDelayMs); + } + // ===== 结束修复 ===== + + Thread.sleep(ot * 1000 + extraDelayMs); + client.send("611", params, response -> { + log.info("[611出牌] 发送完成"); }); + log.info("[611出牌] 总延迟={}ms(基础{}ms+保护{}ms)", + ot * 1000 + extraDelayMs, ot * 1000, extraDelayMs); } catch (Exception e) { log.error("线程执行错误", e); } @@ -1055,17 +1264,24 @@ public class HeChi { * @return */ public String shanchuchuguopai(ITObject param) { - log.info("对面吃碰删除出过的牌组"); if (param == null) { return null; } Integer card = param.getInt("card"); // 操作牌值 Integer type = param.getInt("type"); // 操作类型 - - Integer playerid = param.getInt("playerid"); + String typeName = ""; + switch (type) { + case 1: typeName = "吃"; break; + case 2: typeName = "碰"; break; + case 3: typeName = "明杠"; break; + case 5: typeName = "暗杠/补杠"; break; + default: typeName = "未知(type=" + type + ")"; break; + } + log.info("[815玩家操作] 玩家{}对牌{}执行{}操作", playerid, cardToString(card), typeName); + //Redis中获取机器人ID列表 List robotIdsList = new ArrayList<>(); diff --git a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangShaSuanFaTest.java b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangShaSuanFaTest.java index 8bf5135..c90c4a9 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangShaSuanFaTest.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangShaSuanFaTest.java @@ -12,7 +12,7 @@ import java.util.List; import java.util.stream.Collectors; public class ChangShaSuanFaTest { - public int drawnCards; //摸牌 + public static int drawnCards; //摸牌(静态:支持跨类访问) public static List tinCards = new ArrayList<>(); public static boolean isTin = false; @@ -324,15 +324,45 @@ public class ChangShaSuanFaTest { log.info("resultList22 +++++++++++++++++++++++================ {}", hasBigSuit); - //调平胡递归 - List integers = ChangshaWinSplitCard.analyzeBestDiscard(pinghuhandCards); + //调平胡递归 - 传入红中数量和已吃碰杠牌数以支持万能牌逻辑 + int committedCardCount = chowGroup.size() + pengCard.size() + gangdepai.size(); + List integers = ChangshaWinSplitCard.analyzeBestDiscardWithWildcard(pinghuhandCards, hongZhongCount, committedCardCount); log.debug("integers:{}", integers); if (integers.size() > 0) { int integer = 0; - integer = selectBestCardByPriority(integers); - + // ===== 🚀 新增:智能决策引擎集成 ===== + // 使用多轮模拟前瞻+防守意识+动态策略,提升出牌智能化水平 + try { + Integer intelligentChoice = ChangshaWinSplitCard.intelligentDiscardDecision( + pinghuhandCards, + hongZhongCount, + committedCardCount, + resultList, // 已出牌(用于防守分析) + pengCard, // 碰牌记录 + chowGroup, // 吃牌记录 + gangdepai, // 杠牌记录 + null // 自动判断游戏阶段 + ); + + if (intelligentChoice != null && integers.contains(intelligentChoice)) { + integer = intelligentChoice; + // System.out.println("【智能决策引擎】已接管出牌决策 → " + integer + + // " (原算法候选:" + integers + ")"); //已精简 + } else { + // 智能引擎返回的牌不在基础候选中,使用原逻辑但输出警告 + // System.out.println("【智能决策引擎】结果异常,回退到原算法"); //已精简 + integer = selectBestCardByPriority(integers); + } + } catch (Exception e) { + // 智能引擎出错时安全回退到原有逻辑 + log.warn("智能决策引擎异常: {}", e.getMessage()); + integer = selectBestCardByPriority(integers); + } + // ===== 结束智能决策引擎集成 ===== +// 【重要修复】智能决策引擎已接管出牌时,不再被selectBestCardNoJiang覆盖 +// 原bug:智能引擎选了303,但下面的selectBestCardNoJiang又重新选了一张,导致303永远打不出去 // int duijiangnum = checkduijiang(pinghuhandCards); // // if (integers.size() > 1 && duijiangnum == 0) { @@ -340,8 +370,9 @@ public class ChangShaSuanFaTest { // integer = selectBestCardNoJiang(integers); // } - if (integers.size() > 1) { - integer = selectBestCardNoJiang(integers); + // 仅在智能决策未生效时才使用selectBestCardNoJiang兜底 + if (integer == 0 && integers.size() > 1) { + integer = selectBestCardNoJiang(integers, pinghuhandCards); } //判断是否可以开杠 @@ -364,7 +395,85 @@ public class ChangShaSuanFaTest { } //没有孤章门子 if (guzhangc.size() == 0) { - System.out.println("没有孤章门子出牌11111"); + // System.out.println("没有孤章门子出牌11111"); //已精简 + + // ===== 【关键修复V2】听牌优先原则 ===== + // 核心改进:当智能决策选出的牌可以"听牌"时,不应该被对子保护逻辑覆盖 + // 原bug(Round17): 智能决策选了302(能听),但被安全选择覆盖为304(也能听但次优) + // 修复:先检测当前选择是否可听牌,如果可以则只在"替代品也能听牌"时才允许覆盖 + + // 重新计算当前手牌的听牌列表(用于验证) + List currentTingList = new ArrayList<>(); + try { + int committedForTing = chowGroup.size() / 3 + pengCard.size() / 3 + gangdepai.size() / 4; + // 注意:这里用pinghuhandCards计算听牌(不含红中) + currentTingList = ChangshaWinSplitCard.checktingpaiWithWildcard( + new ArrayList<>(pinghuhandCards), hongZhongCount, committedForTing); + } catch (Exception tingEx) { + // 听牌计算失败时不阻塞,静默处理 + } + + boolean currentChoiceCanTing = !currentTingList.isEmpty() && currentTingList.contains(integer); + + // 【原有对子保护逻辑】增强版:加入听牌感知 + int integerCount = 0; + for (int c : pinghuhandCards) { + if (c == integer) integerCount++; + } + + if (integerCount >= 2 && !currentChoiceCanTing) { + // 当前选择是对子的一部分,且不能听牌 → 可以寻找替代 + // System.out.println("【无孤章-对子保护】选中的出牌" + integer + "是对子的一部分(count=" + integerCount + ")且不能听牌,寻找非对子替代..."); //已精简 + + // 从integers候选列表中找非对子的牌 + Integer nonPairAlternative = null; + for (int alt : integers) { + if (alt == integer) continue; + int altCount = 0; + for (int c : pinghuhandCards) { + if (c == alt) altCount++; + } + if (altCount < 2) { // 非对子牌 + nonPairAlternative = alt; + break; + } + } + + if (nonPairAlternative != null) { + // System.out.println("【无孤章-对子保护生效】改选非对子出牌 " + nonPairAlternative + "(保留" + integer + "对子不拆)"); //已精简 + return String.valueOf(nonPairAlternative); + } else { + System.out.println("【无孤章-无法避免拆对】所有候选都是对子,仍使用原选择" + integer); + } + } else if (integerCount >= 2 && currentChoiceCanTing) { + // 当前选择是对子但能听牌 → 不再强制替换! + // 进一步优化:如果能听牌且有更优的非对子也能听牌的替代,才替换 + Integer betterNonPairTingAlt = null; + for (int alt : integers) { + if (alt == integer) continue; + int altCount = 0; + for (int c : pinghuhandCards) { + if (c == alt) altCount++; + } + // 找一个:是非对子 且 也能听牌 的替代 + if (altCount < 2 && currentTingList.contains(alt)) { + betterNonPairTingAlt = alt; + break; + } + } + + if (betterNonPairTingAlt != null) { + // 存在更优的"非对子+能听牌"替代 + System.out.println(String.format( + "【无孤章-听牌优化】%d是对子且能听,但有更优的非对子%d也能听→替换", + integer, betterNonPairTingAlt)); + return String.valueOf(betterNonPairTingAlt); + } + // 否则保持当前选择(对子+能听牌 > 非对子+不能听牌) + System.out.println(String.format( + "【无孤章-听牌保护】%d虽是对子但能听牌,保持不变(听牌优先于保对子)", integer)); + } + return String.valueOf(integer); } @@ -602,14 +711,172 @@ public class ChangShaSuanFaTest { //河池麻将去掉将牌是指定258的限制 平胡递归出牌 if (!integers.isEmpty()) { - System.out.println("河池麻将去掉将牌是指定258的限制 平胡递归出牌" + integers); + // System.out.println("河池麻将去掉将牌是指定258的限制 平胡递归出牌" + integers); //已精简 //优先打出孤张的风牌 Integer windCard = findWindOrArrowCardToDiscard(integers); if (windCard != null) { return String.valueOf(windCard); } + + // 当有多个候选牌时,比较听牌面选择最优出牌(选择听牌数最多的) + if (integers.size() > 1) { + Map> tingPaiMap = quyizhangTingPai(cardInhand); + int bestTingNum = -1; + Integer bestCard = null; + List tmpres = new ArrayList<>(); + tmpres.addAll(resultList); + tmpres.addAll(cardInhand); - return String.valueOf(integers.get(0)); + for (Integer candidate : integers) { + // 将牌保护:跳过会破坏最后一个对子的出牌 + if (isDiscardBreakingLastPair(candidate, cardInhand)) { + // System.out.println("听牌面比较:跳过" + candidate + "(破坏最后一个对子)"); //已精简 + continue; + } + // 只有在quyizhangTingPai结果中才计算听牌数 + if (tingPaiMap.containsKey(candidate)) { + int tingNum = getTingPainum(tingPaiMap.get(candidate), tmpres); + // System.out.println("听牌面比较:打" + candidate + "可听" + tingNum + "张(" + tingPaiMap.get(candidate) + ")"); //已精简 + if (tingNum > bestTingNum) { + bestTingNum = tingNum; + bestCard = candidate; + } + } + } + + if (bestCard != null) { + // System.out.println("听牌面比较结果:选择打" + bestCard + "(听" + bestTingNum + "张最多)"); //已精简 + return String.valueOf(bestCard); + } + // 如果quyizhangTingPai没有覆盖所有候选(理论上不应该),回退到原有逻辑 + // System.out.println("听牌面比较:quyizhangTingPai未覆盖所有候选,回退到将牌保护逻辑"); //已精简 + } + + // 回退逻辑:通用将牌保护(单个候选或上面比较失败时) + Integer safeDiscard = findSafePairDiscard(integers, cardInhand); + if (safeDiscard != null && !safeDiscard.equals(integers.get(0))) { + System.out.println("将牌保护:跳过原首选" + integers.get(0) + ",选择安全出牌" + safeDiscard); + return String.valueOf(safeDiscard); + } + + // ===== 新增:智能对子保护(防止随意拆散有用的对子)===== + Integer firstChoice = integers.get(0); + + // 检查首选是否是对子的一部分 + int firstChoiceCount = 0; + for (Integer card : cardInhand) { + if (card.equals(firstChoice)) firstChoiceCount++; + } + + if (firstChoiceCount == 2) { + // 首选是对子,调用智能对子保护判断 + List tingCards = handscardshifoutingpai(cardInhand, chowGroup, pengCard, gangdepai); + boolean shouldProtect = shouldProtectPair(firstChoice, cardInhand, tingCards, integers); + + if (shouldProtect) { + // 应该保护这个对子,尝试找孤张替代 + System.out.println(" 智能对子保护:阻止拆对子" + firstChoice + ",尝试寻找孤张替代"); + + Map countMap = new HashMap<>(); + for (Integer card : cardInhand) { + countMap.put(card, countMap.getOrDefault(card, 0) + 1); + } + + // 优先找孤张(count==1)且在候选列表中的牌 + Integer alternative = null; + for (Integer candidate : integers) { + int cnt = countMap.getOrDefault(candidate, 0); + if (cnt == 1) { // 找到孤张 + alternative = candidate; + break; + } + } + + if (alternative != null) { + System.out.println(" 智能对子保护:选择孤张" + alternative + "代替拆对子" + firstChoice); + return String.valueOf(alternative); + } else { + System.out.println(" 智能对子保护:没有找到孤张替代,被迫拆对子" + firstChoice); + } + } else { + System.out.println("智能对子保护:允许拆对子" + firstChoice); + } + } + // ===== 结束:智能对子保护 ===== + + // ===== 新增:灵活决策机制 - 在规则和收益之间找到平衡 ===== + // 核心思想:不要让死板的规则阻碍了更好的听牌机会! + // 场景示例:手牌[103×3, 105×2, 106, 108×2, ...] + // - 规则说:保护108对子不打 → 结果打106(孤张) + // - 但实际上:打105(拆对子)能听更多牌!→ 应该灵活选择105 + // + // 灵活条件:如果拆某个对子/面子能显著增加听牌数(≥2张),值得打破常规 + Integer finalChoice = integers.get(0); + + // 只有当最终选择不是最优解时才触发灵活决策 + if (integers.size() >= 2) { + try { + Integer flexibleBest = findFlexibleBestDiscard(integers, cardInhand, finalChoice); + + if (flexibleBest != null && !flexibleBest.equals(finalChoice)) { + System.out.println(" 灵活决策:放弃原选择" + finalChoice + + ",改选" + flexibleBest + "(听牌收益更大)"); + return String.valueOf(flexibleBest); + } + } catch (Exception e) { + // 灵活决策出错时不影响主流程,静默失败 + System.out.println(" 灵活决策计算异常:" + e.getMessage()); + } + } + // ===== 结束:灵活决策机制 ===== + + // ===== 【核心修复】听牌状态不可逆保护机制 ===== + // 铁律:如果当前已经在听牌状态,绝对不能打出导致退出听牌的牌! + // 场景复现:19:10:49 摸到308,手牌已听[305,308,309],却打305导致退出听牌 ❌ + + // Step1: 检查当前是否已经听牌 + List currentTingCards = handscardshifoutingpai(cardInhand, chowGroup, pengCard, gangdepai); + boolean isCurrentlyTing = (currentTingCards != null && !currentTingCards.isEmpty()); + + if (isCurrentlyTing) { + System.out.println(" 听牌保护检测:当前已听牌[" + currentTingCards.size() + "种] - " + currentTingCards); + + // Step2: 验证最终选择打完后是否还在听牌 + List tempHandAfterDiscard = new ArrayList<>(cardInhand); + tempHandAfterDiscard.remove(Integer.valueOf(finalChoice)); + List tingAfterDiscard = handscardshifoutingpai(tempHandAfterDiscard, chowGroup, pengCard, gangdepai); + boolean stillTing = (tingAfterDiscard != null && !tingAfterDiscard.isEmpty()); + + if (!stillTing) { + System.out.println(" 严重警告:打" + finalChoice + "会导致退出听牌!启动紧急回退..."); + + // Step3: 紧急回退 - 从候选列表找能保持听牌的替代方案 + Integer safeTingChoice = findSafeTingPreservingDiscard(integers, cardInhand, + chowGroup, pengCard, gangdepai); + + if (safeTingChoice != null) { + System.out.println(" 听牌保护成功:改选" + safeTingChoice + "保持听牌[" + + tingAfterDiscard.size() + "种]"); + return String.valueOf(safeTingChoice); + } else { + System.out.println(" 致命错误:所有候选都会导致退出听牌!强制使用最优解"); + // 即使没有完美方案,也要选择听牌数最多的那个 + Integer bestFallback = findBestTingFallback(integers, cardInhand, + chowGroup, pengCard, gangdepai); + if (bestFallback != null) { + return String.valueOf(bestFallback); + } + // 最后兜底:使用原选择(虽然会退听) + System.out.println(" 只能接受退出听牌的结果 - " + finalChoice); + } + } else { + System.out.println(" 听牌保护通过:打" + finalChoice + "后仍然听牌[" + + tingAfterDiscard.size() + "种]"); + } + } + // ===== 结束:听牌状态不可逆保护 ===== + + return String.valueOf(finalChoice); } @@ -639,6 +906,11 @@ public class ChangShaSuanFaTest { for (Map.Entry> entry : afterDahuOp.entrySet()) { log.debug("{}", entry.getKey()); log.debug("num:{}", getTingPainum(entry.getValue(), tmpres)); + // 通用将牌保护:跳过会破坏最后一个对子的出牌 + if (isDiscardBreakingLastPair(entry.getKey(), cardInhand)) { + System.out.println("将牌保护:跳过大胡听牌出牌" + entry.getKey() + "(破坏最后一个对子)"); + continue; + } if (caozuonum1 == 0) { caozuonum1 = getTingPainum(entry.getValue(), tmpres); xuanzecard1 = entry.getKey(); @@ -668,6 +940,11 @@ public class ChangShaSuanFaTest { tmpres.addAll(resultList); tmpres.addAll(cardInhand); for (Map.Entry> entry : afterOp.entrySet()) { + // 通用将牌保护:跳过会破坏最后一个对子的出牌 + if (isDiscardBreakingLastPair(entry.getKey(), cardInhand)) { + System.out.println("将牌保护:跳过平胡听牌出牌" + entry.getKey() + "(破坏最后一个对子)"); + continue; + } if (caozuonum == 0) { caozuonum = getTingPainum(entry.getValue(), tmpres); xuanzecard = entry.getKey(); @@ -785,8 +1062,8 @@ public class ChangShaSuanFaTest { } } - //碰碰胡 - List checktingpai1 = TinHuChi.checktingpai(cardInhand); + //碰碰胡 - 检测听牌状态(使用handscardshifoutingpai替代不存在的TinHuChi.checktingpai) + List checktingpai1 = handscardshifoutingpai(cardInhand, chowGroup, pengCard, gangdepai); if (i >= 3 && chowGroup.size() == 0 && checktingpai1.size() == 0) { if (i == 4) { @@ -882,14 +1159,30 @@ public class ChangShaSuanFaTest { if (analysis.isTingPai) { log.info("已经是听牌状态----{}", drawnCards); System.out.println("已经是听牌状态 就直接出摸的牌"); + // 通用将牌保护:如果摸的牌会破坏最后一个对子,尝试出其他牌 + if (isDiscardBreakingLastPair(drawnCards, pinghuhandCards1)) { + System.out.println("将牌保护:听牌状态不打摸的牌" + drawnCards + "(会破坏对子),尝试找其他出牌"); + for (Integer card : pinghuhandCards1) { + if (!isDiscardBreakingLastPair(card, pinghuhandCards1)) { + System.out.println("将牌保护:替代出牌" + card); + return String.valueOf(card); + } + } + } return String.valueOf(drawnCards); } - //河池麻将去掉将牌是指定258的限制 -// if (isJiangPai(outcard)&&integers.size()>0){ -// return String.valueOf(integers.get(0)); -// } - + //通用将牌保护:兜底出牌时如果会破坏最后一个对子,找替代牌 + if (isDiscardBreakingLastPair(outcard, pinghuhandCards1)) { + System.out.println("将牌保护:兜底出牌" + outcard + "会破坏最后一个对子,尝试找其他出牌"); + for (Integer card : pinghuhandCards1) { + if (!card.equals(outcard) && !isDiscardBreakingLastPair(card, pinghuhandCards1)) { + System.out.println("将牌保护:替代出牌" + card); + return String.valueOf(card); + } + } + System.out.println("将牌保护:无法找到替代出牌,所有牌都会破坏对子或没有其他选择"); + } //兜底算法出牌 System.out.println("最终兜底出的牌" + outcard); @@ -899,7 +1192,7 @@ public class ChangShaSuanFaTest { } // 从候选牌中选出最优的一张牌(河池麻将:任意对子都可以做将) - public static Integer selectBestCardNoJiang(List cards) { + public static Integer selectBestCardNoJiang(List cards, List handCards) { if (cards == null || cards.isEmpty()) { return null; } @@ -909,8 +1202,8 @@ public class ChangShaSuanFaTest { return cards.get(0); } - // 优先选择边张(1或9) - return selectBestSingleCard(cards); + // 优先选择边张(1或9),同时保护对子不拆 + return selectBestSingleCard(cards, handCards); } // ... existing code ... @@ -974,7 +1267,8 @@ public class ChangShaSuanFaTest { // } // 从候选牌中选出最优的一张牌(优先边张1或9) - public static Integer selectBestSingleCard(List cards) { + // 【增强】增加对子保护:在同等优先级下,优先打非对子牌,避免拆对 + public static Integer selectBestSingleCard(List cards, List handCards) { if (cards == null || cards.isEmpty()) { return null; } @@ -984,25 +1278,53 @@ public class ChangShaSuanFaTest { return windCard; } - // 优先找边张(1或9) + // ===== 对子保护增强:先分类对子/非对子 ===== + List nonPairCards = new ArrayList<>(); + List pairCards = new ArrayList<>(); for (Integer card : cards) { - int value = card % 100; - if (value == 1 || value == 9) { - return card; + int count = 0; + if (handCards != null) { + for (int c : handCards) { + if (c == card) count++; + } + } + if (count >= 2) { + pairCards.add(card); + } else { + nonPairCards.add(card); } } - // 没有边张,找靠近边张的牌(2或8)- 注意这里2是258将牌,但我们已经去除了258 - for (Integer card : cards) { - int value = card % 100; - if (value == 2 || value == 8) { - return card; - } - } + // System.out.println("【selectBestSingleCard】对子牌: " + pairCards + ", 非对子牌: " + nonPairCards); //已精简 - // 都没有,返回第一张 + // 优先找边张(1或9)—— 但优先选择非对子的边张 + Integer edgeNonPair = findFirstByValue(nonPairCards, 1, 9); + if (edgeNonPair != null) return edgeNonPair; + Integer edgePair = findFirstByValue(pairCards, 1, 9); + if (edgePair != null) return edgePair; + + // 没有边张,找靠近边张的牌(2或8)—— 同样优先非对子 + Integer nearEdgeNonPair = findFirstByValue(nonPairCards, 2, 8); + if (nearEdgeNonPair != null) return nearEdgeNonPair; + Integer nearEdgePair = findFirstByValue(pairCards, 2, 8); + if (nearEdgePair != null) return nearEdgePair; + + // 都没有,优先返回非对子牌的第一张 + if (!nonPairCards.isEmpty()) return nonPairCards.get(0); return cards.get(0); } + + // 辅助方法:从列表中找指定值的牌 + private static Integer findFirstByValue(List cards, int v1, int v2) { + if (cards == null) return null; + for (Integer card : cards) { + int value = card % 100; + if (value == v1 || value == v2) { + return card; + } + } + return null; + } public static Integer selectBestCardByPriority(List cards) { if (cards == null || cards.isEmpty()) { @@ -1547,15 +1869,20 @@ public class ChangShaSuanFaTest { } - // 4. 如果以上情况都没有,打出剩余随便哪张 + // 4. 如果以上情况都没有,打出剩余随便哪张(但保护对子) if (!candidates.isEmpty()) { - discardCard = candidates.iterator().next(); + // 通用将牌保护:优先选择不会破坏最后一个对子的牌 + List candidateList = new ArrayList<>(candidates); + Integer safeCard = findSafePairDiscard(candidateList, cardInhand); + if (safeCard != null) { + discardCard = safeCard; + } else { + // 所有候选牌都会破坏对子,只能选第一个(极端情况) + discardCard = candidateList.get(0); + } logInfo("最后选择任意剩余牌: " + discardCard); -// if (discardCard != 5 && discardCard != 2 && discardCard != 8) { return discardCard; -// } - } @@ -3459,6 +3786,1006 @@ public class ChangShaSuanFaTest { return false; } + /** + * 统计手牌中对子数量(任意对子都可以做将) + * @param handCards 手牌 + * @return 对子数量(count==2的牌种数,3张以上的刻子不算对子) + */ + public static int countJiangPairs(List handCards) { + Map countMap = new HashMap<>(); + for (Integer card : handCards) { + countMap.put(card, countMap.getOrDefault(card, 0) + 1); + } + int pairCount = 0; + for (Integer count : countMap.values()) { + if (count == 2) { + pairCount++; + } + } + return pairCount; + } + + /** + * 检查打出某张牌后,手牌是否还有对子 + * 河池麻将必须有一对将才能胡牌,所以需要保护对子不被打光 + * @param discardCard 要打出的牌 + * @param handCards 当前手牌(包含要打出的牌) + * @return true=打后没有对子了(不应打出),false=打后仍有对子(可以打出) + */ + public static boolean isDiscardBreakingLastPair(int discardCard, List handCards) { + // 非万筒条牌不涉及将牌限制(风牌字牌一般不做将) + int suit = discardCard / 100; + if (suit < 1 || suit > 3) return false; + + // 统计该牌在手牌中的数量 + int cardCount = 0; + for (Integer card : handCards) { + if (card.equals(discardCard)) cardCount++; + } + + // 如果该牌不是对子的一部分(只有1张或>=3张),打出不影响对子数量 + if (cardCount != 2) return false; + + // 该牌恰好是2张(对子),检查打出后剩余对子数量 + List tempHand = new ArrayList<>(handCards); + tempHand.remove(Integer.valueOf(discardCard)); + int remainingPairs = countJiangPairs(tempHand); + + // 打出后没有对子了 → 不应打出 + if (remainingPairs == 0) { + System.out.println("将牌保护:打出" + discardCard + "会破坏最后一个对子!当前对子数=1"); + return true; + } + return false; + } + + /** + * 【新增】检查是否应该保护某个对子不被拆散 + * 核心思想:不要随意拆掉有用的对子,除非: + * 1. 已经听牌(为了胡牌必须拆) + * 2. 有足够的孤张可以打(不需要拆对子) + * 3. 这个对子确实阻碍了牌型发展 + * + * @param discardCard 要打出的牌(假设是对子的一部分) + * @param handCards 当前手牌 + * @param canTingCards 能听牌的列表(如果已经听牌则非空) + * @param allCandidates 所有候选出牌列表(来自算法推荐) + * @return true=应该保护这个对子(不要拆),false=可以拆 + */ + public static boolean shouldProtectPair(int discardCard, List handCards, + List canTingCards, List allCandidates) { + int suit = discardCard / 100; + // 只保护万筒条对子 + if (suit < 1 || suit > 3) return false; + + // 确认这是对子(恰好2张) + int cardCount = 0; + for (Integer card : handCards) { + if (card.equals(discardCard)) cardCount++; + } + if (cardCount != 2) return false; + + // 场景1:如果已经能听牌了,且打这张牌是为了听牌 → 允许(听牌优先) + if (canTingCards != null && !canTingCards.isEmpty()) { + if (canTingCards.contains(discardCard)) { + System.out.println("对子保护:允许拆对子" + discardCard + "(为了听牌)"); + return false; // 允许拆 + } + } + + // 场景2:如果候选列表中全部都是对子(没有孤张可打)→ 允许拆最差的对子 + if (allCandidates != null && !allCandidates.isEmpty()) { + boolean hasNonPairCandidate = false; + for (Integer candidate : allCandidates) { + int candidateCount = 0; + for (Integer card : handCards) { + if (card.equals(candidate)) candidateCount++; + } + // 如果存在非对子的候选牌(孤张或刻子),说明不需要强制拆对子 + if (candidateCount != 2) { + hasNonPairCandidate = true; + break; + } + } + + if (!hasNonPairCandidate) { + System.out.println("对子保护:所有候选都是对子,被迫拆对子" + discardCard); + return false; // 被迫拆对子 + } + } + + // 场景3:统计手牌中的孤张数量 + Map countMap = new HashMap<>(); + for (Integer card : handCards) { + countMap.put(card, countMap.getOrDefault(card, 0) + 1); + } + + int isolatedCount = 0; + for (Map.Entry entry : countMap.entrySet()) { + if (entry.getValue() == 1) { + isolatedCount++; + } + } + + // 如果孤张数量 >= 2,说明还有其他牌可以打,不应该急着拆对子 + if (isolatedCount >= 2) { + System.out.println("对子保护:阻止拆对子" + discardCard + + "(手牌还有" + isolatedCount + "张孤张可打)"); + return true; // 保护对子 + } + + // 孤张很少(0-1张),可以考虑拆对子 + System.out.println("对子保护:允许拆对子" + discardCard + + "(孤张仅" + isolatedCount + "张,确实没别的牌可打)"); + return false; // 允许拆 + } + + /** + * 【新增】检查某张牌是否属于已经完成的面子(顺子或刻子) + * 核心思想:已经成型的面子是有价值的,不应该轻易破坏 + * + * @param card 要检查的牌 + * @param handCards 当前手牌(包含该牌) + * @return true=该牌属于已完成的顺子或刻子,false=不属于 + */ + public static boolean isCardInCompletedMeld(int card, List handCards) { + // 统计该牌在手牌中的数量 + int count = 0; + for (Integer c : handCards) { + if (c.equals(card)) count++; + } + + // 检查1:是否属于刻子(3张或4张相同牌)→ 这个是明确的,始终保护 + if (count >= 3) { + System.out.println(" 面子保护:" + card + "属于刻子(" + count + "张),应保护"); + return true; + } + + // 检查2:是否属于顺子(连续3张同花色牌) + // 【增强】增加"连牌重叠检测",避免误判灵活的连牌材料为固定面子 + // 只有万筒条才可能组成顺子 + int suit = card / 100; + if (suit >= 1 && suit <= 3) { + int value = card % 100; + + // 检查可能的顺子组合 + int[][] possibleMelds = { + {value - 2, value - 1, value}, // x-2,x-1,x + {value - 1, value, value + 1}, // x-1,x,x+1 ← 最常见 + {value, value + 1, value + 2} // x,x+1,x+2 + }; + + for (int[] meld : possibleMelds) { + boolean isCompleteMeld = true; + + // 检查顺子中的每一张是否都在手牌中 + for (int v : meld) { + if (v < 1 || v > 9) { + isCompleteMeld = false; + break; + } + + int meldCard = suit * 100 + v; + int meldCount = 0; + for (Integer c : handCards) { + if (c.equals(meldCard)) meldCount++; + } + + if (meldCount == 0) { + isCompleteMeld = false; + break; + } + } + + if (isCompleteMeld) { + // ===== 【新增】连牌重叠检测 ===== + // 如果同花色有4张及以上连续的牌(如206,207,208,209),则这些牌是"灵活材料" + // 不应该被当作固定的"已完成面子"来保护,因为它们可以有多种分解方式 + int consecutiveCount = countConsecutiveCardsInSuit(handCards, suit, meld[0], meld[2]); + + if (consecutiveCount > 3) { + // 发现4+张连续牌 → 这是灵活组合材料,不强制保护 + System.out.println(String.format( + " 连牌检测:%s属于[%d%d%d]但%d花色有%d张连续牌(>3),视为灵活材料不强制保护", + card, suit*100+meld[0], suit*100+meld[1], suit*100+meld[2], + suit, consecutiveCount)); + continue; // 跳过这个顺子组合,检查下一个 + } + + String meldDesc = suit * 100 + meld[0] + "," + suit * 100 + meld[1] + "," + suit * 100 + meld[2]; + System.out.println(" 面子保护:" + card + "属于顺子[" + meldDesc + "],应保护"); + return true; + } + } + } + + return false; + } + + /** + * 【新增】计算指定花色在给定值域范围内的最大连续牌数 + * 用于判断是否存在"4张以上连续牌"(灵活材料 vs 固定面子) + * + * @param handCards 手牌 + * @param suit 花色(1=万,2=筒,3=条) + * @param startValue 起始值(包含) + * @param endValue 结束值(包含) + * @return 该范围内的连续牌数 + */ + private static int countConsecutiveCardsInSuit(List handCards, int suit, int startValue, int endValue) { + // 扩展搜索范围为 [startValue-1, endValue+1] 以检测更广泛的连续性 + int searchStart = Math.max(1, startValue - 1); + int searchEnd = Math.min(9, endValue + 1); + + int maxConsecutive = 0; + int currentConsecutive = 0; + + for (int v = searchStart; v <= searchEnd; v++) { + int targetCard = suit * 100 + v; + boolean exists = false; + for (Integer c : handCards) { + if (c.equals(targetCard)) { + exists = true; + break; + } + } + + if (exists) { + currentConsecutive++; + maxConsecutive = Math.max(maxConsecutive, currentConsecutive); + } else { + currentConsecutive = 0; + } + } + + return maxConsecutive; + } + + /** + * 从候选出牌列表中,选择一个不会打破最后一个对子的牌 + * 【增强版V2】同时保护已完成的顺子和刻子不被破坏 + 智能价值评估 + * + * 核心改进:当没有完美方案时,不再简单返回第一个候选, + * 而是对所有候选进行价值评估,选择损失最小的那个。 + * + * @param candidates 候选出牌列表 + * @param handCards 当前手牌 + * @return 安全的候选牌(优先不破坏对子和面子),如果没有则返回null + */ + public static Integer findSafePairDiscard(List candidates, List handCards) { + // ===== 新增预排序:按"孤立程度"升序排列,优先检查最孤立的牌 ===== + // 这样第一轮就能找到真正的孤张,避免错误地选中"有备份的牌" + List sortedCandidates = new ArrayList<>(candidates); + sortedCandidates.sort((a, b) -> { + int isolationA = calculateIsolationScore(a, handCards); + int isolationB = calculateIsolationScore(b, handCards); + return Integer.compare(isolationA, isolationB); // 孤立分数低的排前面 + }); + + // 第一轮:找既不破坏对子也不破坏面子的牌,且是"真正的孤张"(最佳选择) + for (Integer candidate : sortedCandidates) { + if (!isDiscardBreakingLastPair(candidate, handCards) && + !isCardInCompletedMeld(candidate, handCards)) { + + // 【关键新增】检查是否为"有备份的牌"(手中有>=2张) + // 如果打掉这张牌后还有同花色同点数的牌,说明不是最优选择 + int cardCount = 0; + for (Integer c : handCards) { + if (c.equals(candidate)) cardCount++; + } + if (cardCount >= 2) { + // 这张牌有备份(如202x2),打掉一张还剩一张 + // 继续寻找更优的"唯一孤张" + System.out.println(String.format(" 候选%d有%d张备份,跳过寻找更优孤张", candidate, cardCount)); + continue; + } + + System.out.println(" 安全选择:" + candidate + "(既不破坏对子也不破坏面子,且为唯一孤张)"); + return candidate; + } + } + + // 第一轮回退:如果没有完美孤张,找至少不破坏对子和面子的牌(允许有备份) + for (Integer candidate : sortedCandidates) { + if (!isDiscardBreakingLastPair(candidate, handCards) && + !isCardInCompletedMeld(candidate, handCards)) { + System.out.println(" 安全选择(回退):" + candidate + "(不破坏对子和面子,但可能有备份)"); + return candidate; + } + } + + // 第二轮:【优化】智能价值评估排序 + // 当没有完美方案时,收集所有"不破坏对子"的候选,按价值排序选最优 + List secondRoundCandidates = new ArrayList<>(); + + for (Integer candidate : candidates) { + if (!isDiscardBreakingLastPair(candidate, handCards)) { + // 计算该候选的综合价值评分 + int score = evaluateDiscardCandidate(candidate, handCards); + boolean breaksMeld = isCardInCompletedMeld(candidate, handCards); + + secondRoundCandidates.add(new DiscardCandidate(candidate, score, breaksMeld)); + + System.out.println(String.format(" 候选%d: 价值分=%d, 破坏面子=%b", + candidate, score, breaksMeld)); + } + } + + if (!secondRoundCandidates.isEmpty()) { + // 按价值评分排序(分数越低越好) + Collections.sort(secondRoundCandidates); + + DiscardCandidate best = secondRoundCandidates.get(0); + + if (best.breaksMeld) { + System.out.println(String.format( + " 兜底选择:%s(价值分=%d,虽破坏面子但是最优解)", + best.card, best.score)); + } else { + System.out.println(String.format( + " 兜底选择:%s(价值分=%d,不破坏任何结构)", + best.card, best.score)); + } + + return best.card; + } + + // ===== 第三轮兜底:【新增】检查是否有"超级孤张"可以替代 ===== + // 当所有候选都会破坏对子时,检查手牌中是否有完全孤立的牌 + // 这些牌不在原始候选列表中(因为算法认为它们不该打),但实际上它们是最该打的 + System.out.println("【孤张扫描】所有候选都破坏对子/面子,开始扫描手牌中的完全孤张..."); + + Integer superIsolatedCard = findSuperIsolatedCard(candidates, handCards); + if (superIsolatedCard != null) { + System.out.println(String.format( + " 发现超级孤张:%s(完全孤立,优于破坏结构的候选)", superIsolatedCard)); + return superIsolatedCard; + } + + System.out.println("将牌保护:所有候选牌都会破坏对子,且无更优孤张可替代!"); + return null; + } + + /** + * 出牌候选的价值评估内部类 + */ + private static class DiscardCandidate implements Comparable { + Integer card; + int score; // 价值评分(越低越好) + boolean breaksMeld; + + public DiscardCandidate(Integer card, int score, boolean breaksMeld) { + this.card = card; + this.score = score; + this.breaksMeld = breaksMeld; + } + + @Override + public int compareTo(DiscardCandidate other) { + // 分数升序排列(分数越小的优先) + return Integer.compare(this.score, other.score); + } + } + + /** + * 【核心】评估某张牌作为出牌候选的综合价值 + * + * 评分规则(分数越低=越应该被打出): + * - 基础分:0-10000 + * - 加分项(增加分数=不应该打出): + * + 属于顺子/刻子: +3000 + * 属于有用搭子(两面/嵌张): +1000 + * 属于对子: +2000 + * 属于风牌/箭牌且是孤张: -500(鼓励打风牌) + * 刚摸的孤张(手牌中只有1张): -200 + * + * @param card 待评估的牌 + * @param handCards 手牌 + * @return 价值评分(越低越好) + */ + private static int evaluateDiscardCandidate(int card, List handCards) { + int score = 1000; // 基础分 + + int suit = card / 100; + int value = card % 100; + + // 统计该牌数量 + int count = 0; + for (Integer c : handCards) { + if (c.equals(card)) count++; + } + + // 1. 如果属于已完成的顺子或刻子 → 大幅加分(不该打) + if (isCardInCompletedMeld(card, handCards)) { + score += 3000; + } + + // 2. 如果是对子的一部分 → 加分 + if (count == 2) { + score += 2000; + } + + // 3. 如果是有用的搭子 → 加分 + if (isUsefulConnector(card, handCards)) { + score += 1000; + } + + // 4. 如果是风牌/箭牌且为孤张 → 减分(鼓励打出) + if (suit >= 4 && count == 1) { + score -= 500; + } + + // 5. 完全孤张(手牌只有1张且无关联)→ 减分 + if (count == 1 && !hasRelatedCards(card, handCards)) { + score -= 200; + } + + return score; + } + + /** + * 【新增】计算牌的孤立程度分数(越低越孤立,应该优先打出) + * + * 评估维度: + * 1. 手牌中的数量(数量越多越不孤立) + * 2. 是否有关联牌(形成搭子/连张/对子) + * 3. 花色类型(风牌/箭牌更孤立) + * + * @param card 要评估的牌 + * @param handCards 当前手牌 + * @return 孤立分数(0=最孤立,越高越不孤立) + */ + private static int calculateIsolationScore(int card, List handCards) { + int score = 0; + int suit = card / 100; + int value = card % 100; + + // 统计该牌在手牌中的数量 + int count = 0; + for (Integer c : handCards) { + if (c.equals(card)) count++; + } + + // 维度1:数量加分(数量越多越不该打) + if (count >= 3) { + score += 3000; // 刻子,非常不应该打 + } else if (count == 2) { + score += 1500; // 对子,不应该轻易拆 + } + // count == 1 不加分,本身就是候选 + + // 维度2:关联性加分 + if (suit >= 1 && suit <= 3) { + // 数值牌:检查相邻牌 + boolean hasNeighbor = false; + int neighborCount = 0; + for (int delta = -2; delta <= 2; delta++) { + if (delta == 0) continue; + + int neighborValue = value + delta; + if (neighborValue >= 1 && neighborValue <= 9) { + int neighborCard = suit * 100 + neighborValue; + for (Integer c : handCards) { + if (c.equals(neighborCard)) { + hasNeighbor = true; + neighborCount++; + } + } + } + } + + if (hasNeighbor) { + score += 500 + neighborCount * 200; // 有邻居则加分 + } + + // 边张(1,9)比中张(2-8)稍微孤立一些 + if (value == 1 || value == 9) { + score -= 100; // 边张稍微减分(更该打) + } + } else { + // 风牌/箭牌:通常比较孤立 + score -= 200; + } + + return score; + } + + /** + * 【新增】从手牌中找出"超级孤张" + * + * 超级孤张的定义: + * 1. 不在候选列表中(算法原本没推荐它) + * 2. 手牌中只有1张 + * 3. 无任何关联牌(不形成搭子/连张) + * 4. 不属于任何已完成的顺子/刻子 + * + * @param excludedCards 已排除的候选列表(这些是算法推荐的,但可能都破坏结构) + * @param handCards 当前手牌 + * @return 最孤立的牌,如果没有则返回null + */ + private static Integer findSuperIsolatedCard(List excludedCards, List handCards) { + // 构建排除集合 + Set excludeSet = new HashSet<>(excludedCards); + + // 统计每张牌的数量 + Map cardCountMap = new HashMap<>(); + for (Integer card : handCards) { + cardCountMap.put(card, cardCountMap.getOrDefault(card, 0) + 1); + } + + List isolatedCards = new ArrayList<>(); + + for (Map.Entry entry : cardCountMap.entrySet()) { + int card = entry.getKey(); + int count = entry.getValue(); + + // 条件1:不在候选列表中 + if (excludeSet.contains(card)) { + continue; + } + + // 条件2:只有1张(如果是>=2张,可能是对子,不应该优先打) + if (count != 1) { + continue; + } + + // 条件3:无关联牌 + boolean hasRelatedCards = hasRelatedCards(card, handCards); + + // 条件4:不属于已完成的面子 + boolean isInMeld = isCardInCompletedMeld(card, handCards); + + // 条件5:不会破坏最后一个对子 + boolean breaksPair = isDiscardBreakingLastPair(card, handCards); + + if (!hasRelatedCards && !isInMeld && !breaksPair) { + // 这是一张超级孤张!计算其孤立程度 + int isolationScore = calculateIsolationScore(card, handCards); + isolatedCards.add(new IsolatedCardInfo(card, isolationScore)); + + System.out.println(String.format( + " 发现孤张:%s(孤立分=%d)", card, isolationScore)); + } + } + + if (isolatedCards.isEmpty()) { + System.out.println(" 未发现超级孤张"); + return null; + } + + // 按孤立程度排序(分数越低越孤立,越应该打出) + Collections.sort(isolatedCards); + + return isolatedCards.get(0).card; + } + + /** + * 孤张信息内部类(用于排序) + */ + private static class IsolatedCardInfo implements Comparable { + Integer card; + int isolationScore; // 孤立分数(越低越孤立) + + IsolatedCardInfo(Integer card, int isolationScore) { + this.card = card; + this.isolationScore = isolationScore; + } + + @Override + public int compareTo(IsolatedCardInfo other) { + return Integer.compare(this.isolationScore, other.isolationScore); // 升序 + } + } + + /** + * 检查某张牌是否与其他牌有关联性(形成搭子或连张) + */ + private static boolean hasRelatedCards(int card, List handCards) { + int suit = card / 100; + int value = card % 100; + + if (suit >= 1 && suit <= 3) { + // 检查是否有相邻的牌(±1或±2) + for (int delta = -2; delta <= 2; delta++) { + if (delta == 0) continue; + + int neighborValue = value + delta; + if (neighborValue >= 1 && neighborValue <= 9) { + int neighborCard = suit * 100 + neighborValue; + + for (Integer c : handCards) { + if (c.equals(neighborCard)) { + return true; + } + } + } + } + } + + return false; + } + + /** + * 检查某张牌是否属于有用的搭子 + */ + private static boolean isUsefulConnector(int card, List handCards) { + int suit = card / 100; + int value = card % 100; + + if (suit < 1 || suit > 3) return false; + if (value < 1 || value > 9) return false; + + // 检查是否能形成两面搭子或嵌张搭子 + // 例如:有3和5,那么4就是嵌张;有3和4,那么2或5就是两面 + + int[][] usefulPatterns = { + {value - 2, value}, // 卡张: x-2, x (需要x-1) + {value - 1, value + 1}, // 两面: x-1, x+1 (需要x) + {value, value + 2} // 卡张: x, x+2 (需要x+1) + }; + + for (int[] pattern : usefulPatterns) { + int v1 = pattern[0]; + int v2 = pattern[1]; + + if (v1 < 1 || v1 > 9 || v2 < 1 || v2 > 9) continue; + + int card1 = suit * 100 + v1; + int card2 = suit * 100 + v2; + + boolean hasCard1 = false; + boolean hasCard2 = false; + + for (Integer c : handCards) { + if (c.equals(card1)) hasCard1 = true; + if (c.equals(card2)) hasCard2 = true; + } + + if (hasCard1 && hasCard2) { + return true; + } + } + + return false; + } + + /** + * 【新增】灵活决策方法 - 在规则保护和听牌收益之间找到最佳平衡 + * + * 核心思想: + * 麻将不是死板的数学题,有时候需要"打破常规"来获得更大收益。 + * 当严格的保护逻辑(对子保护、面子保护)导致错过更好的听牌机会时, + * 应该灵活调整,选择收益更大的选项。 + * + * 灵活决策的触发条件(必须同时满足): + * 1. 当前选择(经过保护逻辑后的结果)能听牌 + * 2. 存在另一个被保护逻辑阻止的选项,它的听牌数显著更多(>=2张) + * 3. 这个选项虽然会破坏某个结构(对子/顺子),但值得牺牲 + * + * @param candidates 所有候选出牌列表 + * @param handCards 当前手牌 + * @param currentChoice 当前经过保护逻辑后的首选 + * @return 更优的灵活选择(如果有),否则返回null + */ + public static Integer findFlexibleBestDiscard(List candidates, List handCards, + Integer currentChoice) { + if (candidates == null || candidates.size() < 2 || currentChoice == null) { + return null; + } + + // Step1: 计算当前选择的听牌数量 + int currentTingNum = calculateTingNumForDiscard(currentChoice, handCards); + + if (currentTingNum < 0) { + // 当前选择不能听牌,不触发灵活决策(避免把情况搞得更糟) + return null; + } + + // Step2: 寻找更优的灵活选项 + Integer bestFlexible = null; + int bestFlexibleTingNum = currentTingNum; + String bestReason = ""; + + for (Integer candidate : candidates) { + if (candidate.equals(currentChoice)) continue; // 跳过当前选择 + + // 计算这个候选的听牌数量 + int candidateTingNum = calculateTingNumForDiscard(candidate, handCards); + + if (candidateTingNum <= 0) continue; // 不能听牌的跳过 + + // 判断是否值得灵活处理 + int improvement = candidateTingNum - currentTingNum; + + if (improvement >= 2) { // 至少多听2张才值得 + // 分析这个候选会被什么保护逻辑阻止 + String blockReason = analyzeWhyBlocked(candidate, handCards); + + // 判断是否可以灵活处理 + boolean canFlexible = shouldAllowFlexibility(candidate, handCards, improvement); + + if (canFlexible && candidateTingNum > bestFlexibleTingNum) { + bestFlexible = candidate; + bestFlexibleTingNum = candidateTingNum; + bestReason = String.format( + "打%s可听%d张(比%s多%d张), 原因:%s", + candidate, candidateTingNum, currentChoice, improvement, + blockReason.isEmpty() ? "正常选项" : "被阻止-" + blockReason + ); + } + } + } + + // Step3: 如果找到了更优选项,返回它 + if (bestFlexible != null) { + System.out.println(String.format( + "灵活决策分析完成:\n" + + " 当前选择: %s (听%d张)\n" + + " 更优选项: %s (听%d张)\n" + + " → 原因: %s", + currentChoice, currentTingNum, + bestFlexible, bestFlexibleTingNum, + bestReason + )); + return bestFlexible; + } + + return null; // 没有找到更优的灵活选项 + } + + /** + * 计算打出某张牌后能听多少张牌 + * @param discardCard 要打出的牌 + * @param handCards 当前手牌(包含要打出的牌) + * @return 听牌的数量(>=0),如果出错或不能听返回-1 + */ + private static int calculateTingNumForDiscard(int discardCard, List handCards) { + try { + // 模拟打出这张牌 + List tempHand = new ArrayList<>(handCards); + tempHand.remove(Integer.valueOf(discardCard)); + + // 使用现有的听牌检测方法 + Map tingResult = new HashMap<>(); + List chowGroup = new ArrayList<>(); // 吃牌组 + List pengCard = new ArrayList<>(); // 碰牌组 + List gangdepai = new ArrayList<>(); // 杠牌组 + + // 调用河池麻将的听牌检测 + List tingCards = handscardshifoutingpai(tempHand, chowGroup, pengCard, gangdepai); + + return tingCards != null ? tingCards.size() : 0; + } catch (Exception e) { + return -1; + } + } + + /** + * 分析某张牌为什么会被保护逻辑阻止 + */ + private static String analyzeWhyBlocked(int card, List handCards) { + StringBuilder reasons = new StringBuilder(); + + if (isDiscardBreakingLastPair(card, handCards)) { + reasons.append("破坏最后对子 "); + } + + if (isCardInCompletedMeld(card, handCards)) { + reasons.append("属于已完成的面子 "); + } + + return reasons.toString().trim(); + } + + /** + * 判断是否应该允许灵活处理(即打破某个保护规则) + * + * 允许灵活的条件: + * 1. 听牌提升显著(>=2张) + * 2. 手牌中还有其他对子可用作将牌(不会完全失去将牌来源) + * 3. 不是破坏唯一的刻子/顺子(保留基本牌型价值) + */ + private static boolean shouldAllowFlexibility(int card, List handCards, int improvement) { + // 条件1: 手牌中必须还有至少2个对子(拆掉一个还剩一个做将) + List tempHand = new ArrayList<>(handCards); + tempHand.remove(Integer.valueOf(card)); + int remainingPairs = countJiangPairs(tempHand); + + if (remainingPairs < 1) { + System.out.println(" 灵活性检查:" + card + " - 拆后仅剩" + remainingPairs + "个对子,不允许"); + return false; + } + + // 条件2: 提升幅度足够大(>=2张) + if (improvement < 2) { + System.out.println(" 灵活性检查:" + card + " - 提升" + improvement + "张不足够"); + return false; + } + + // 条件3: 特殊场景 - 如果手牌质量很差(差手数大),更加鼓励灵活处理 + Map qualityMap = new HashMap<>(); + List qualityCheck = new ArrayList<>(handCards); + ChangshaWinSplitCard.checkNormalHu(qualityCheck, qualityMap); + + int shanten = 4; // 默认值:差手数很大 + if (qualityMap.containsKey("shanten")) { + try { + shanten = Integer.parseInt(qualityMap.get("shanten").toString()); + } catch (Exception e) { /* 使用默认值 */ } + } + + // 如果差手数>=2(离听牌还很远),更加愿意灵活处理 + if (shanten >= 2 && improvement >= 2) { + System.out.println(String.format( + " 灵活性通过:%s - 差手数=%d, 提升%d张, 剩余对子=%d ", + card, shanten, improvement, remainingPairs + )); + return true; + } + + // 一般情况:需要提升>=3张才允许灵活处理 + if (improvement >= 3) { + System.out.println(String.format( + " 灵活性通过:%s - 提升%d张显著, 剩余对子=%d ", + card, improvement, remainingPairs + )); + return true; + } + + System.out.println(String.format( + " 灵活性拒绝:%s - 提升%d张不够(一般需≥3), 剩余对子=%d ", + card, improvement, remainingPairs + )); + return false; + } + + /** + * 【核心方法】寻找能保持听牌状态的安全出牌 + * 当当前已听牌,但原选择会导致退出听牌时调用 + * + * @param candidates 候选出牌列表 + * @param handCards 当前手牌 + * @param chowGroup 吃牌组 + * @param pengCard 碰牌组 + * @param gangdepai 杠牌组 + * @return 能保持听牌的最佳候选牌(优先不破坏对子/面子),如果没有则返回null + */ + private static Integer findSafeTingPreservingDiscard(List candidates, List handCards, + List chowGroup, List pengCard, + List gangdepai) { + if (candidates == null || candidates.isEmpty()) { + return null; + } + + // 收集所有能保持听牌的候选及其属性 + class TingCandidate implements Comparable { + Integer card; + int tingNum; + boolean breaksPair; + boolean breaksMeld; + + public TingCandidate(Integer card, int tingNum, boolean breaksPair, boolean breaksMeld) { + this.card = card; + this.tingNum = tingNum; + this.breaksPair = breaksPair; + this.breaksMeld = breaksMeld; + } + + @Override + public int compareTo(TingCandidate other) { + // 优先级:听牌数多 > 不破坏对子 > 不破坏面子 > 原始顺序 + if (this.tingNum != other.tingNum) { + return other.tingNum - this.tingNum; // 听牌数降序 + } + if (this.breaksPair != other.breaksPair) { + return this.breaksPair ? 1 : -1; // 不破坏对子的优先 + } + if (this.breaksMeld != other.breaksMeld) { + return this.breaksMeld ? 1 : -1; // 不破坏面子的优先 + } + return 0; + } + } + + List safeCandidates = new ArrayList<>(); + + for (Integer candidate : candidates) { + // 模拟打出这张牌 + List tempHand = new ArrayList<>(handCards); + tempHand.remove(Integer.valueOf(candidate)); + + // 检查打完后是否还能听牌 + List tingAfterDiscard = handscardshifoutingpai(tempHand, chowGroup, pengCard, gangdepai); + + if (tingAfterDiscard != null && !tingAfterDiscard.isEmpty()) { + // 能保持听牌!记录这个候选 + boolean breaksPair = isDiscardBreakingLastPair(candidate, handCards); + boolean breaksMeld = isCardInCompletedMeld(candidate, handCards); + + safeCandidates.add(new TingCandidate( + candidate, + tingAfterDiscard.size(), + breaksPair, + breaksMeld + )); + + System.out.println(String.format( + " 候选%s: 听%d张, 破对子=%b, 破面子=%b", + candidate, tingAfterDiscard.size(), breaksPair, breaksMeld + )); + } else { + System.out.println(" 候选" + candidate + ": 会导致退出听牌,跳过"); + } + } + + if (safeCandidates.isEmpty()) { + System.out.println(" 所有候选都会导致退出听牌!"); + return null; + } + + // 排序选择最佳候选 + Collections.sort(safeCandidates); + TingCandidate best = safeCandidates.get(0); + + System.out.println(String.format( + " 选择最优候选:%s(听%d张)%s%s", + best.card, + best.tingNum, + best.breaksPair ? "[拆对子]" : "", + best.breaksMeld ? "[拆面子]" : "" + )); + + return best.card; + } + + /** + * 【兜底方法】当所有候选都会导致退出听牌时,选择损失最小的那个 + * 即使必须退听,也要选择退听后手牌质量最好的(差手数最小) + */ + private static Integer findBestTingFallback(List candidates, List handCards, + List chowGroup, List pengCard, + List gangdepai) { + Integer bestChoice = null; + int bestShanten = Integer.MAX_VALUE; + + for (Integer candidate : candidates) { + try { + List tempHand = new ArrayList<>(handCards); + tempHand.remove(Integer.valueOf(candidate)); + + // 计算打完后的差手数 + Map qualityMap = new HashMap<>(); + ChangshaWinSplitCard.checkNormalHu(tempHand, qualityMap); + + int shanten = 4; // 默认值 + if (qualityMap.containsKey("shanten")) { + shanten = Integer.parseInt(qualityMap.get("shanten").toString()); + } + + System.out.println(String.format( + " 兜底评估 %s: 差手数=%d", + candidate, shanten + )); + + if (shanten < bestShanten) { + bestShanten = shanten; + bestChoice = candidate; + } + } catch (Exception e) { + // 忽略计算异常 + } + } + + if (bestChoice != null) { + System.out.println(String.format( + " 兜底选择:%s(差手数=%d,损失最小)", + bestChoice, bestShanten + )); + } + + return bestChoice; + } + public static int checkduijiang(List cardInHand) { // Map countMap = new HashMap<>(); // for (Integer item : cardInHand) { @@ -3650,6 +4977,37 @@ public class ChangShaSuanFaTest { return false; } + /** + * 检查出牌是否受河池麻将吃牌门子限制 + * 规则:如果吃的牌属于147/258/369组,同个门子(同花色)同组的牌不能打出 + * 147组: value%3==1 (1,4,7) + * 258组: value%3==2 (2,5,8) + * 369组: value%3==0 (3,6,9) + * 需要检查吃的三张牌,因为三张牌都可能触发限制 + */ + public static boolean isChowMenziRestricted(int discardCard, int chowCard1, int chowCard2, int chowCard3) { + // 【已禁用】河池麻将无147/258/369吃牌门子出牌限制规则 + return false; + } + + /** + * 过滤掉受门子规则限制的出牌 + * @param tingMap 打出某牌后可以听牌的映射 (discardCard → tingCards) + * @param chowCard1 吃牌的第一张(别人打的牌) + * @param chowCard2 吃牌的第二张(手牌出的牌) + * @param chowCard3 吃牌的第三张(手牌出的牌) + * @return 过滤后的映射(只包含不受门子限制的出牌) + */ + public static Map> filterMenziRestricted(Map> tingMap, int chowCard1, int chowCard2, int chowCard3) { + Map> filtered = new HashMap<>(); + for (Map.Entry> entry : tingMap.entrySet()) { + if (!isChowMenziRestricted(entry.getKey(), chowCard1, chowCard2, chowCard3)) { + filtered.put(entry.getKey(), entry.getValue()); + } + } + return filtered; + } + /** * 通过结果计算最优分数并返回对应的选择id * @@ -3862,11 +5220,13 @@ public class ChangShaSuanFaTest { List tmpChangSch = new ArrayList<>(); tmpChangSch.addAll(yupanhandcard); ChangshaWinSplitCard.checkNormalHu(tmpChangSch, map); - System.out.println("map:" + map); + // System.out.println("map:" + map); //已精简 + + //操作后,去一张牌,分析求最优 Map map2 = new HashMap<>(); - System.out.println(checkCards); + // System.out.println(checkCards); //已精简 Map> xiatingList = new HashMap<>(); List peng = new ArrayList<>(); //新new peng.add(card); @@ -3877,8 +5237,8 @@ public class ChangShaSuanFaTest { peng.add(card); peng.add(card); - System.out.println("map2:" + map2); - System.out.println("xiatingList:" + xiatingList); + // System.out.println("map2:" + map2); //已精简 + // System.out.println("xiatingList:" + xiatingList); //已精简 //比对 if (map2.size() > 0) { //可以下听 @@ -3917,13 +5277,13 @@ public class ChangShaSuanFaTest { int tmp = 0; /*if (entry.getValue().getBoolean("isTing")){ tmp= tmp+30; - }*/ + }*/ if (chiob.getBoolean("xiaoDahu")) { tmp = tmp - 20; } /* if (entry.getValue().getInt("isDaHu")>0){ tmp= tmp-10; - }*/ + }*/ // if (chiob.getBoolean("xiaoJiang")) { // tmp = tmp - 13; // } @@ -3948,6 +5308,59 @@ public class ChangShaSuanFaTest { if (chiob.getInt("guzhang") > 0) { tmp = tmp + chiob.getInt("guzhang") * 2; } + + // ===== 新增:手牌质量评估 - 当手牌很差时鼓励碰掉风牌/字牌对子加速推进 ===== + // 核心思想:如果手牌整体质量很差(孤张多、差手数大),碰掉风牌/字牌对子可以: + // 1. 多打一张相对没用的牌加速推进 + // 2. 避免最后自己把这两张风牌打出去(浪费机会) + // 适用场景:唯一的风牌对子 + 其他牌型很差 + boolean isWindOrArrowCard = (card / 100 == 4 || card / 100 == 5); // 401-404风牌, 501-503字牌 + if (isWindOrArrowCard) { + // 获取操作前的手牌质量指标 + Map qualityMap = new HashMap<>(); + List tmpQualityCheck = new ArrayList<>(yupanhandcard); + ChangshaWinSplitCard.checkNormalHu(tmpQualityCheck, qualityMap); + + int remainingMeldsBefore = 4; // 默认值:需要4组面子 + int isolatedCardsBefore = yupanhandcard.size(); // 默认:所有牌都是孤张 + if (qualityMap.containsKey("remainingMelds")) { + remainingMeldsBefore = Integer.parseInt(qualityMap.get("remainingMelds").toString()); + } + if (qualityMap.containsKey("cardResiue")) { + List residueList = (List) qualityMap.get("cardResiue"); + isolatedCardsBefore = residueList.size(); + } + + // 判断是否为"手牌质量差"的场景 + // 条件1:差手数 >= 2(距离听牌还很远) + // 条件2:孤张数量 >= 5(手牌很散) + boolean isPoorHandQuality = (remainingMeldsBefore >= 2 && isolatedCardsBefore >= 5); + + if (isPoorHandQuality) { + // 手牌质量差时,碰掉风牌/字牌对子的优势: + // - 可以多打一张散牌,加速推进 + // - 这对子留着也是浪费,最后还得自己打出去 + int bonusScore = 8; // 基础加分 + + // 差手数越多,越应该积极碰(每多一手加3分) + bonusScore += (remainingMeldsBefore - 2) * 3; + + // 孤张越多,越应该积极碰(每多一张孤张加1分) + bonusScore += (isolatedCardsBefore - 5) * 1; + + tmp += bonusScore; + + System.out.println(String.format( + "【 手牌质量评估-碰牌加分】碰%s(风牌/字牌), 操作前:差手数=%d, 孤张=%d → 加分=%d, 当前总分=%d", + card, remainingMeldsBefore, isolatedCardsBefore, bonusScore, tmp)); + } else { + System.out.println(String.format( + "【 手牌质量评估】碰%s(风牌/字牌), 但手牌质量还行(差手数=%d, 孤张=%d), 不加分", + card, remainingMeldsBefore, isolatedCardsBefore)); + } + } + // ===== 结束:手牌质量评估 ===== + //如果评分大于0,且小于5 if (tmp > 0 && tmp <= 5) { //去掉将时候 @@ -4006,7 +5419,7 @@ public class ChangShaSuanFaTest { chiob.putInt("teshu", 0); List checkCards = new ArrayList(); checkCards.addAll(yupanhandcard); - System.out.println("运行之前:" + checkCards); + // System.out.println("运行之前:" + checkCards); //已精简 //去掉三张牌是否还能听牌 List nchi = new ArrayList(); @@ -4030,20 +5443,30 @@ public class ChangShaSuanFaTest { } + // 吃牌门子限制:吃牌后打出某张牌时,如果该牌与吃的三张牌同门子同组(147/258/369),则不能打出 + // 三张吃牌:card(别人打的) + opcard[0] + opcard[1] + int chowCard1 = card; + int chowCard2 = opcard.size() > 0 ? opcard.getInt(0) : 0; + int chowCard3 = opcard.size() > 1 ? opcard.getInt(1) : 0; + Map> afterdaHuOp = quyizhangDahuTingPai(checkCards, nchi, pengGrop, gangGrop); - if (afterdaHuOp.size() > 0) { + // 过滤掉受门子限制的出牌(吃牌后这些牌不能打出,因此不能用来评估能否听牌) + Map> filteredDaHuOp = filterMenziRestricted(afterdaHuOp, chowCard1, chowCard2, chowCard3); + if (filteredDaHuOp.size() > 0) { chiob.putBoolean("canTing", true); chiob.putBoolean("sanTing", false); } else { //循环去一张还能听牌 Map> afterOp = quyizhangTingPai(checkCards); + // 过滤掉受门子限制的出牌 + Map> filteredOp = filterMenziRestricted(afterOp, chowCard1, chowCard2, chowCard3); - if (afterOp.size() == 0 && beforelisten) { + if (filteredOp.size() == 0 && beforelisten) { chiob.putBoolean("sanTing", true); chiob.putBoolean("canTing", false); } - if (afterOp.size() > 0) { + if (filteredOp.size() > 0) { chiob.putBoolean("canTing", true); chiob.putBoolean("sanTing", false); } else { @@ -4085,11 +5508,11 @@ public class ChangShaSuanFaTest { List tmpChangSch = new ArrayList<>(); tmpChangSch.addAll(yupanhandcard); ChangshaWinSplitCard.checkNormalHu(tmpChangSch, map); - System.out.println("map:" + map); + // System.out.println("map:" + map); //已精简 //操作后,去一张牌,分析求最优 Map map2 = new HashMap<>(); - System.out.println(checkCards); + // System.out.println(checkCards); //已精简 Map> xiatingList = new HashMap<>(); List chi = new ArrayList<>(); //新new chi.addAll(chowGroup); @@ -4104,8 +5527,11 @@ public class ChangShaSuanFaTest { } - System.out.println("map2:" + map2); - System.out.println("xiatingList:" + xiatingList); + // System.out.println("map2:" + map2); //已精简 + // System.out.println("xiatingList:" + xiatingList); //已精简 + // 过滤掉受门子限制的出牌(吃牌后不能打出的牌不能用于听牌评估) + Map> filteredXiatingList = filterMenziRestricted(xiatingList, chowCard1, chowCard2, chowCard3); + // System.out.println("filteredXiatingList(去掉门子限制牌):" + filteredXiatingList); //已精简 //比对 if (map2.size() > 0) { //可以下听 @@ -4127,9 +5553,9 @@ public class ChangShaSuanFaTest { caozuoqiannum = getTingPainum(caozuoqian, allpai); } int caozuohounum = 0; - if (xiatingList.size() > 0) { - //获取能听牌数最多的 - for (Map.Entry> entry : xiatingList.entrySet()) { + if (filteredXiatingList.size() > 0) { + //获取能听牌数最多的(只考虑不受门子限制的出牌) + for (Map.Entry> entry : filteredXiatingList.entrySet()) { if (caozuohounum == 0) { caozuohounum = getTingPainum(entry.getValue(), allpai); } @@ -4140,10 +5566,16 @@ public class ChangShaSuanFaTest { } chiob.putInt("tingNum", caozuohounum - caozuoqiannum); - System.out.println("chiob:" + chiob); + // System.out.println("chiob:" + chiob); //已精简 return chiob; } + /** + * 计算听牌数量:对于听牌列表中的每张牌,计算其在剩余牌堆中可能出现的次数(最多4张减去已出现的) + * @param caozq 听牌列表(可以胡的牌) + * @param changShachuguopai 已出现过的牌(桌面上已打出的牌等) + * @return 总共可听的牌数 + */ public static int getTingPainum(List caozq, List changShachuguopai) { int num = 0; for (int i = 0; i < caozq.size(); i++) { @@ -4165,11 +5597,25 @@ public class ChangShaSuanFaTest { * @return */ public static List handscardshifoutingpai(List cardhand, List chowGroup, List pongGroup, List gangdepai) { + // 河池麻将:自动检测红中(501),从手牌中移除后作为万能牌传入WinCard + int hongZhongCount = 0; + List handWithoutHZ = new ArrayList<>(); + for (Integer card : cardhand) { + if (card == 501) { + hongZhongCount++; + } else { + handWithoutHZ.add(card); + } + } + List tpcards = new ArrayList<>(); List tmphc = new ArrayList<>(); - tmphc.addAll(cardhand); + tmphc.addAll(handWithoutHZ); + + // System.out.println("handscardshifoutingpai 手牌(不含红中):" + handWithoutHZ + ", 红中数量:" + hongZhongCount); //已精简(被quyizhangChayou高频调用) + for (int j = 101; j <= 109; j++) { - WinCard win = new WinCard(tmphc, j); + WinCard win = new WinCard(tmphc, j, hongZhongCount, 501); if (win.tryWin()) { if (!tpcards.contains(j)) { tpcards.add(j); @@ -4188,7 +5634,7 @@ public class ChangShaSuanFaTest { } for (int k = 201; k <= 209; k++) { - WinCard win = new WinCard(tmphc, k); + WinCard win = new WinCard(tmphc, k, hongZhongCount, 501); if (win.tryWin()) { if (!tpcards.contains(k)) { @@ -4208,7 +5654,7 @@ public class ChangShaSuanFaTest { } for (int l = 301; l <= 309; l++) { - WinCard win = new WinCard(tmphc, l); + WinCard win = new WinCard(tmphc, l, hongZhongCount, 501); if (win.tryWin()) { if (!tpcards.contains(l)) { tpcards.add(l); @@ -4224,7 +5670,7 @@ public class ChangShaSuanFaTest { } } for (int m = 401; m <= 404; m++) { - WinCard win = new WinCard(tmphc, m); + WinCard win = new WinCard(tmphc, m, hongZhongCount, 501); if (win.tryWin()) { if (!tpcards.contains(m)) { tpcards.add(m); @@ -4240,7 +5686,7 @@ public class ChangShaSuanFaTest { } } for (int n = 502; n <= 503; n++) { - WinCard win = new WinCard(tmphc, n); + WinCard win = new WinCard(tmphc, n, hongZhongCount, 501); if (win.tryWin()) { if (!tpcards.contains(n)) { tpcards.add(n); @@ -4258,6 +5704,7 @@ public class ChangShaSuanFaTest { //如果没有则检测大胡 + // System.out.println("handscardshifoutingpai 听牌结果:" + tpcards); //已精简(被814高频调用) return tpcards; } @@ -4327,17 +5774,135 @@ public class ChangShaSuanFaTest { return map; } + /** + * 河池麻将听牌检测(支持红中万能牌)- 已分离红中的版本 + * @param cardhand 不含红中的手牌列表 + * @param chowGroup 吃牌组 + * @param pongGroup 碰牌组 + * @param gangdepai 杠牌组 + * @param hongZhongCount 红中数量 + * @return 听牌结果列表(可以胡哪些牌) + */ + public static List handscardshifoutingpaiWithWildcard(List cardhand, List chowGroup, List pongGroup, List gangdepai, int hongZhongCount) { + if (cardhand == null || cardhand.isEmpty() && hongZhongCount == 0) { + return new ArrayList<>(); + } + + List tpcards = new ArrayList<>(); + List tmphc = new ArrayList<>(); + tmphc.addAll(cardhand); + + // System.out.println("handscardshifoutingpaiWithWildcard 手牌(不含红中):" + cardhand + ", 红中数量:" + hongZhongCount); //已精简 + + for (int j = 101; j <= 109; j++) { + WinCard win = new WinCard(tmphc, j, hongZhongCount, 501); + if (win.tryWin()) { + if (!tpcards.contains(j)) { + tpcards.add(j); + } + } + + Paixing px = new Paixing(); + List pxc = new ArrayList<>(); + pxc.addAll(tmphc); + if (px.tingCheck(pxc, chowGroup, pongGroup, gangdepai)) { + if (!tpcards.contains(j)) { + tpcards.add(j); + } + } + } + for (int k = 201; k <= 209; k++) { + WinCard win = new WinCard(tmphc, k, hongZhongCount, 501); + if (win.tryWin()) { + if (!tpcards.contains(k)) { + tpcards.add(k); + } + } + + Paixing px = new Paixing(); + List pxc2 = new ArrayList<>(); + pxc2.addAll(tmphc); + if (px.tingCheck(pxc2, chowGroup, pongGroup, gangdepai)) { + if (!tpcards.contains(k)) { + tpcards.add(k); + } + } + } + for (int l = 301; l <= 309; l++) { + WinCard win = new WinCard(tmphc, l, hongZhongCount, 501); + if (win.tryWin()) { + if (!tpcards.contains(l)) { + tpcards.add(l); + } + } + Paixing px = new Paixing(); + List pxc3 = new ArrayList<>(); + pxc3.addAll(tmphc); + if (px.tingCheck(pxc3, chowGroup, pongGroup, gangdepai)) { + if (!tpcards.contains(l)) { + tpcards.add(l); + } + } + } + for (int m = 401; m <= 404; m++) { + WinCard win = new WinCard(tmphc, m, hongZhongCount, 501); + if (win.tryWin()) { + if (!tpcards.contains(m)) { + tpcards.add(m); + } + } + Paixing px = new Paixing(); + List pxc4 = new ArrayList<>(); + pxc4.addAll(tmphc); + if (px.tingCheck(pxc4, chowGroup, pongGroup, gangdepai)) { + if (!tpcards.contains(m)) { + tpcards.add(m); + } + } + } + for (int n = 502; n <= 503; n++) { + WinCard win = new WinCard(tmphc, n, hongZhongCount, 501); + if (win.tryWin()) { + if (!tpcards.contains(n)) { + tpcards.add(n); + } + } + Paixing px = new Paixing(); + List pxc5 = new ArrayList<>(); + pxc5.addAll(tmphc); + if (px.tingCheck(pxc5, chowGroup, pongGroup, gangdepai)) { + if (!tpcards.contains(n)) { + tpcards.add(n); + } + } + } + + // System.out.println("handscardshifoutingpaiWithWildcard 听牌结果:" + tpcards); //已精简(被814高频调用) + return tpcards; + } + public static Map> quyizhangTingPai(List cardhand) { + // 河池麻将:自动检测红中,移除后作为万能牌使用 + int hongZhongCount = 0; + List handWithoutHZ = new ArrayList<>(); + for (Integer card : cardhand) { + if (card == 501) { + hongZhongCount++; + } else { + handWithoutHZ.add(card); + } + } + Map> quxiatingmap = new HashMap<>(); - List tmphc = cardhand; + List tmphc = new ArrayList<>(handWithoutHZ); - for (int i = 0; i < cardhand.size(); i++) { + for (int i = 0; i < handWithoutHZ.size(); i++) { int tmpcard = tmphc.get(0); tmphc.remove(0); List huziting = new ArrayList<>(); for (int j = 101; j <= 109; j++) { - WinCard win = new WinCard(tmphc, j); + WinCard win = new WinCard(tmphc, j, hongZhongCount, 501); if (win.tryWin()) { if (!huziting.contains(j)) { huziting.add(j); @@ -4345,7 +5910,7 @@ public class ChangShaSuanFaTest { } } for (int k = 201; k <= 209; k++) { - WinCard win = new WinCard(tmphc, k); + WinCard win = new WinCard(tmphc, k, hongZhongCount, 501); if (win.tryWin()) { if (!huziting.contains(k)) { huziting.add(k); @@ -4354,7 +5919,7 @@ public class ChangShaSuanFaTest { } // 条(301-309) for (int l = 301; l <= 309; l++) { - WinCard win = new WinCard(tmphc, l); + WinCard win = new WinCard(tmphc, l, hongZhongCount, 501); if (win.tryWin()) { if (!huziting.contains(l)) { huziting.add(l); @@ -4363,7 +5928,7 @@ public class ChangShaSuanFaTest { } // 风牌(401-404) for (int m = 401; m <= 404; m++) { - WinCard win = new WinCard(tmphc, m); + WinCard win = new WinCard(tmphc, m, hongZhongCount, 501); if (win.tryWin()) { if (!huziting.contains(m)) { huziting.add(m); @@ -4372,7 +5937,7 @@ public class ChangShaSuanFaTest { } // 发财/白板(502-503) for (int n = 502; n <= 503; n++) { - WinCard win = new WinCard(tmphc, n); + WinCard win = new WinCard(tmphc, n, hongZhongCount, 501); if (win.tryWin()) { if (!huziting.contains(n)) { huziting.add(n); @@ -4394,7 +5959,7 @@ public class ChangShaSuanFaTest { List tmphc = new ArrayList<>(); tmphc.addAll(cardhand); - System.out.println("tmphc1:" + tmphc); + // System.out.println("tmphc1:" + tmphc); //已精简(被pingguChi/pingguPeng高频调用) for (int i = 0; i < cardhand.size(); i++) { int tmpcard = tmphc.get(0); tmphc.remove(0); diff --git a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangshaWinSplitCard.java b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangshaWinSplitCard.java index bcbbe39..e00a126 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangshaWinSplitCard.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/ChangshaWinSplitCard.java @@ -131,8 +131,8 @@ public class ChangshaWinSplitCard { haveBetween(cardResiue, cardInHand); - System.out.println("可以打出的牌:" + cardResiue); - System.out.println("总共差几手牌:" + map.get("remainingMelds")); + // System.out.println("可以打出的牌:" + cardResiue); //已精简 + // System.out.println("总共差几手牌:" + map.get("remainingMelds")); //已精简 return group; } @@ -437,7 +437,7 @@ public class ChangshaWinSplitCard { public static void haveBetween(List cards, List cardInHand) { //添加空指针检查 - if (cards == null || cardInHand == null || + if (cards == null || cardInHand == null || cards.size() <= 1 || cardInHand.size() <= 1) { return; } @@ -451,6 +451,28 @@ public class ChangshaWinSplitCard { handCardTypes.put(handCard, handCard / 100); } + // 统计手牌中每张牌的数量(用于对子检测) + Map handCardCounts = new HashMap<>(); + for (int handCard : cardInHand) { + handCardCounts.put(handCard, handCardCounts.getOrDefault(handCard, 0) + 1); + } + + // ===== 新增:预计算每张候选牌的"连接度"用于后续智能选择 ===== + // 连接度 = 同花色且差值≤2的牌的数量(包括自身) + Map connectionDegree = new HashMap<>(); + for (int card : cards) { + int cardType = card / 100; + if (cardType < 1 || cardType > 3) continue; // 只有万筒条计算连接度 + int degree = 0; + for (int handCard : cardInHand) { + if (handCard / 100 == cardType && Math.abs(card - handCard) <= 2) { + degree++; + } + } + connectionDegree.put(card, degree); + } + // System.out.println("候选牌连接度(越高说明越有靠章): " + connectionDegree); //已精简 + //遍历cards中的每一张牌 for (int card : cards) { int cardType = card / 100; @@ -471,14 +493,423 @@ public class ChangshaWinSplitCard { } } } + + // ===== 新增优化:识别"绝对孤张"并给予最高出牌优先级 ===== + // 绝对孤张定义:在手牌中只有1张(count==1),且同花色没有±2范围内的其他牌 + List absoluteIsolatedCards = new ArrayList<>(); + for (int card : cards) { + int count = handCardCounts.getOrDefault(card, 0); + Integer degree = connectionDegree.get(card); + + // 绝对孤张条件:单张 + 无靠章(连接度<=1,只有自身) + 风牌字牌自动算孤张 + int cardType = card / 100; + if ((count == 1 && degree != null && degree <= 1) || (cardType >= 4 && count == 1)) { + absoluteIsolatedCards.add(card); + } + } + if (!absoluteIsolatedCards.isEmpty()) { + // System.out.println("发现绝对孤张(最高优先打出): " + absoluteIsolatedCards); //已精简 + } + + // 对子保护逻辑:检查剩余候选牌中是否有"对子中的牌"需要保护 + Set pairCardsToProtect = new HashSet<>(); + int totalPairsInHand = 0; // 手牌总对子数 + for (Integer cnt : handCardCounts.values()) { + if (cnt == 2) totalPairsInHand++; + } + + for (int card : cards) { + // 如果这张牌没有被靠章规则移除(即被认为是"孤张") + if (!toRemove.contains(card)) { + int count = handCardCounts.getOrDefault(card, 0); + // 如果这张牌在手牌中恰好是2张(是一对),且总对子数不多 + if (count == 2 && totalPairsInHand <= 4) { + pairCardsToProtect.add(card); + // System.out.println("对子靠章保护:" + card + " 是对子的一部分(count=2),暂不作为首选出牌"); //已精简 + } + } + } + + // 如果存在非对子的可选牌,则将对子牌也移除(保护对子不拆) + boolean hasNonPairOption = false; + for (int card : cards) { + if (!toRemove.contains(card) && !pairCardsToProtect.contains(card)) { + hasNonPairOption = true; + break; + } + } + + if (hasNonPairOption && !pairCardsToProtect.isEmpty()) { + toRemove.addAll(pairCardsToProtect); + // System.out.println("对子保护生效:存在非对子选项,保护对子牌 " + pairCardsToProtect + " 不被打出"); //已精简 + } else if (!pairCardsToProtect.isEmpty()) { + // 尝试从已标记删除的靠章牌中恢复一张非对子牌,避免强制拆对 + Integer recoveredNonPair = null; + for (Integer card : cards) { + if (toRemove.contains(card)) { + int count = handCardCounts.getOrDefault(card, 0); + if (count < 2) { // 非对子牌(即使是靠章牌,也比拆对好) + recoveredNonPair = card; + break; + } + } + } + if (recoveredNonPair != null) { + toRemove.remove(recoveredNonPair); + pairCardsToProtect.clear(); + // System.out.println("对子保护恢复:恢复非对子靠章牌 " + recoveredNonPair + " 作为候选,避免强制拆对"); //已精简 + } else { + // 核心原则:尽量不拆对子!如果必须拆,只拆一个且选择连接度最低的 + // 注意:河池麻将任意对子都可以做将,不存在"258必须做将"的规则 - // 只有当cards中还有多于1张牌时才执行删除 - if (cards.size() - toRemove.size() > 0) { - cards.removeAll(toRemove); + // System.out.println("对子保护跳过:所有靠章牌也都是对子,启动智能拆对选择 " + pairCardsToProtect); //已精简 + + if (pairCardsToProtect.size() > 1) { + // 多个对子时,只拆连接度最低的那一个,其余全部保留 + Integer lowestDegreePair = selectByConnectionDegree(new ArrayList<>(pairCardsToProtect), connectionDegree, handCardCounts); + if (lowestDegreePair == null) lowestDegreePair = pairCardsToProtect.iterator().next(); + + for (Integer pairCard : pairCardsToProtect) { + if (!pairCard.equals(lowestDegreePair)) { + toRemove.remove(pairCard); + } + } + System.out.println("【智能拆对】多对子时仅拆连接度最低 " + lowestDegreePair + "(degree=" + connectionDegree.getOrDefault(lowestDegreePair, 0) + "),保留其余"); + } else { + // System.out.println("【智能拆对】仅有单个对子" + pairCardsToProtect + ",无法避免(检查是否真的必须拆)"); //已精简 + } + } } - System.out.println("删除的牌: " + toRemove); - System.out.println("cards剩余: " + cards); + // 边界情况处理:如果所有候选都被标记为"有靠章"需要删除 + // 则需要对子优先保留逻辑:将对子牌从toRemove中豁免,优先打出纯孤张 + if (toRemove.size() >= cards.size() && !toRemove.isEmpty()) { + // 所有候选都被标记了靠章,检查哪些是对子牌可以豁免 + Set pairsToExempt = new HashSet<>(); + List nonPairToRemove = new ArrayList<>(); + + for (int card : cards) { + if (toRemove.contains(card)) { + int count = handCardCounts.getOrDefault(card, 0); + // 对子牌(count=2)且手牌中总对子数>=2时,豁免不删除 + if (count == 2 && totalPairsInHand >= 2) { + pairsToExempt.add(card); + // System.out.println("边界情况-对子豁免:" + card + " 是对子的一部分,暂不从候选中移除"); //已精简 + } else { + nonPairToRemove.add(card); + } + } + } + + // 如果有可豁免的对子牌,且有非对子的牌可以被删除 + if (!pairsToExempt.isEmpty() && !nonPairToRemove.isEmpty()) { + toRemove.removeAll(pairsToExempt); + // System.out.println("边界情况处理:豁免对子牌 " + pairsToExempt + ",实际删除 " + toRemove); //已精简 + + // 二次检查:豁免后如果剩余候选全是对子,尝试再恢复一张非对子靠章牌 + List remainingAfterExempt = new ArrayList<>(); + for (Integer card : cards) { + if (!toRemove.contains(card)) { + remainingAfterExempt.add(card); + } + } + boolean allPairs = true; + for (Integer card : remainingAfterExempt) { + if (handCardCounts.getOrDefault(card, 0) < 2) { + allPairs = false; + break; + } + } + if (allPairs && remainingAfterExempt.size() > 0 && !nonPairToRemove.isEmpty()) { + // 从nonPairToRemove中恢复连接度最低的一张,避免候选集全是对子 + Integer bestRecover = selectByConnectionDegree(nonPairToRemove, connectionDegree, handCardCounts); + if (bestRecover == null) { + bestRecover = nonPairToRemove.get(0); + } + toRemove.remove(bestRecover); + // System.out.println("边界情况二次修正:豁免后候选全是对子,恢复非对子牌 " + bestRecover + "(degree=" + connectionDegree.getOrDefault(bestRecover, 0) + ") 避免强制拆对"); //已精简 + } + } else if (nonPairToRemove.size() > 1) { + // ===== 新增优化:多个非对子候选时,用连接度评分选择最优出牌 ===== + Integer bestToRemove = selectByConnectionDegree(nonPairToRemove, connectionDegree, handCardCounts); + if (bestToRemove != null && !bestToRemove.equals(nonPairToRemove.get(0))) { + toRemove.clear(); + toRemove.addAll(nonPairToRemove); + toRemove.remove(bestToRemove); + // System.out.println("边界情况智能选牌:选择连接度最低的 " + bestToRemove + " 出牌(degree=" + connectionDegree.get(bestToRemove) + ")"); //已精简 + } + } + } + + // 执行删除操作 + Set actualToRemove = new HashSet<>(toRemove); + if (!actualToRemove.isEmpty() && cards.size() > actualToRemove.size()) { + // ===== 新增:确保绝对孤张被保留在候选中 ===== + List remainingCandidates = new ArrayList<>(); + for (Integer card : cards) { + if (!actualToRemove.contains(card)) remainingCandidates.add(card); + } + + boolean hasAbsoluteIsolatedRemaining = false; + for (Integer card : remainingCandidates) { + if (absoluteIsolatedCards.contains(card)) { + hasAbsoluteIsolatedRemaining = true; + break; + } + } + + if (!hasAbsoluteIsolatedRemaining && !absoluteIsolatedCards.isEmpty()) { + for (Integer isolated : absoluteIsolatedCards) { + for (Iterator it = actualToRemove.iterator(); it.hasNext();) { + Integer removable = it.next(); + if (handCardCounts.getOrDefault(removable, 0) < 2) { + it.remove(); + actualToRemove.remove(removable); + // System.out.println("孤张优先替换:" + isolated + " 替换 " + removable); //已精简 + break; + } + } + break; + } + } + + cards.removeAll(actualToRemove); + } else if (actualToRemove.size() >= cards.size()) { + // 极端情况:优化 - 用连接度评分选择最优保留 + Integer lastReserve = null; + + // 第一优先级:保留绝对孤张 + for (Integer card : cards) { + if (absoluteIsolatedCards.contains(card)) { + lastReserve = card; + // System.out.println("极端情况-保留绝对孤张:" + card); //已精简 + break; + } + } + + // 第二优先级:选连接度最低的非对子 + if (lastReserve == null) { + int minDegree = Integer.MAX_VALUE; + for (int card : cards) { + int count = handCardCounts.getOrDefault(card, 0); + if (count != 2) { + Integer degree = connectionDegree.get(card); + if (degree != null && degree < minDegree) { + minDegree = degree; + lastReserve = card; + } + } + } + if (lastReserve != null) + // System.out.println("极端情况-按连接度:" + lastReserve + "(degree=" + minDegree + ")"); //已精简 + {} + } + + // 第三优先级:原有逻辑 + if (lastReserve == null) { + for (int card : cards) { + int count = handCardCounts.getOrDefault(card, 0); + if (count != 2) { lastReserve = card; break; } + } + } + if (lastReserve == null) lastReserve = cards.get(0); + actualToRemove.remove(lastReserve); + cards.removeAll(actualToRemove); + // System.out.println("极端情况保留: " + lastReserve); //已精简 + } + + // System.out.println("删除的牌: " + actualToRemove); //已精简 + // System.out.println("cards剩余: " + cards); //已精简 + } + + /** + * 根据连接度从候选牌中选择最优的一张(选择连接度最低的) + * 用于多个非对子候选时智能选牌 + */ + private static Integer selectByConnectionDegree(List candidates, Map connectionDegree, Map handCardCounts) { + if (candidates == null || candidates.isEmpty()) return null; + + Integer bestChoice = null; + int minDegree = Integer.MAX_VALUE; + + // 优先选择非对子中连接度最低的 + for (Integer card : candidates) { + int count = handCardCounts.getOrDefault(card, 0); + Integer degree = connectionDegree.get(card); + + if (count < 2 && degree != null && degree < minDegree) { + minDegree = degree; + bestChoice = card; + } + } + + // 如果全是对子,就选连接度最低的对子 + if (bestChoice == null) { + for (Integer card : candidates) { + Integer degree = connectionDegree.get(card); + if (degree != null && degree < minDegree) { + minDegree = degree; + bestChoice = card; + } + } + } + + return bestChoice; + } + + /** + * 对出牌候选进行智能排序(孤张优先) + * 排序规则:绝对孤张(连接度=1) > 弱孤张(连接度=2-3) > 靠章牌(连接度>3) + */ + private static List sortCandidatesByIsolation(List candidates, List cardInHand) { + if (candidates == null || candidates.size() <= 1 || cardInHand == null) { + return candidates != null ? new ArrayList<>(candidates) : new ArrayList<>(); + } + + // 统计手牌中每张牌的数量 + Map handCardCounts = new HashMap<>(); + for (int c : cardInHand) { + handCardCounts.put(c, handCardCounts.getOrDefault(c, 0) + 1); + } + + // 计算每张候选的"孤立评分"(越低越应该优先打出) + // 评分 = 连接度 * 10 + (是否对子 ? 5 : 0) + 牌面值权重 + Map isolationScore = new LinkedHashMap<>(); + for (Integer card : candidates) { + int score = calculateIsolationScore(card, cardInHand, handCardCounts); + isolationScore.put(card, score); + } + // System.out.println("候选牌孤立评分(越低优先): " + isolationScore); //已精简 + + // 按评分升序排列 + List sortedList = new ArrayList<>(candidates); + sortedList.sort((a, b) -> { + int scoreA = isolationScore.getOrDefault(a, 100); + int scoreB = isolationScore.getOrDefault(b, 100); + + // 先按孤立评分 + if (scoreA != scoreB) { + return Integer.compare(scoreA, scoreB); + } + + // 评分相同,非对子优先 + int countA = handCardCounts.getOrDefault(a, 0); + int countB = handCardCounts.getOrDefault(b, 0); + if ((countA < 2 && countB >= 2)) return -1; + if ((countA >= 2 && countB < 2)) return 1; + + // 再按边张优先 + int valueA = a % 100, valueB = b % 100; + boolean isEdgeA = (valueA == 1 || valueA == 9), isEdgeB = (valueB == 1 || valueB == 9); + if (isEdgeA && !isEdgeB) return -1; + if (!isEdgeA && isEdgeB) return 1; + + return Integer.compare(valueA, valueB); + }); + + return sortedList; + } + + /** + * 计算一张牌的"孤立评分" + * 越低说明这张牌越孤,越应该被优先打出 + * 【增强】增加连牌保护:形成连牌结构的牌获得额外保护分 + */ + private static int calculateIsolationScore(Integer card, List cardInHand, Map handCardCounts) { + int cardType = card / 100; + int count = handCardCounts.getOrDefault(card, 0); + + // 基础分:对子加分(不希望拆对子) + int baseScore = (count >= 2) ? 10 : 0; + + // 风牌字牌自动给最低分(最应先打) + if (cardType >= 4) { + return baseScore + 1; // 风牌字牌:单张直接打,对子稍后 + } + + // 计算连接度 + int connectionDegree = 0; + for (int handCard : cardInHand) { + if (handCard / 100 == cardType && Math.abs(card - handCard) <= 2) { + connectionDegree++; + } + } + + // ===== 新增:连牌保护加成 ===== + // 如果一张牌是连续序列(如456、567)的一部分,打出它会破坏有效结构 + // 给予额外保护分,避免拆散连牌 + int chainBonus = calculateChainProtection(card, cardInHand, cardType); + + // 孤立评分 = 连接度 * 5 + 基础分 + 连牌保护 + // 连接度=1表示绝对孤张,得分最低,优先打出 + // 有连牌保护的牌得分更高(更不容易被打出) + int finalScore = baseScore + connectionDegree * 5 + chainBonus; + + if (chainBonus > 0) { + // System.out.println("连牌保护激活:" + card + " 获得保护分+" + chainBonus + "(总评=" + finalScore + ")"); //已精简 + } + + return finalScore; + } + + /** + * 计算"连牌保护"分数 + * 检查一张牌是否是连续序列的一部分,如果是则返回保护分 + * + * 保护规则: + * - 三连牌中间张(如567的6):保护分+15(核心牌,绝对不能打) + * - 三连牌端张(如567的5或7):保护分+10(重要组成部分) + * - 二连牌(如56或67的两张):保护分+5(有潜力) + */ + private static int calculateChainProtection(Integer card, List cardInHand, int cardType) { + int cardValue = card % 100; // 牌面值 1-9 + if (cardValue < 1 || cardValue > 9) return 0; + + // 检查左右相邻牌是否存在 + boolean hasLeft = false; // card-1 是否在手牌中 + boolean hasRight = false; // card+1 是否在手牌中 + boolean hasLeft2 = false; // card-2 是否在手牌中 + boolean hasRight2 = false; // card+2 是否在手牌中 + + for (int handCard : cardInHand) { + if (handCard / 100 != cardType) continue; + int value = handCard % 100; + if (value == cardValue - 1) hasLeft = true; + if (value == cardValue + 1) hasRight = true; + if (value == cardValue - 2) hasLeft2 = true; + if (value == cardValue + 2) hasRight2 = true; + } + + // 判断连牌类型并返回保护分 + // 三连牌:X-1, X, X+1 都存在 → 这张牌在连续三张的中间或端点 + if (hasLeft && hasRight) { + // 形成了 X-1, X, X+1 的三连牌结构 + // 这是最强的连牌,无论这张牌在中间还是作为一部分,都应保护 + return 15; // 强保护:这是连牌的核心部分 + } + + // 二连牌:只有一侧有相邻牌 + if (hasLeft || hasRight) { + // 检查是否能延伸成更长的链 + // 例如手牌有 5,6,8 → 5和6是二连牌,但8是孤张 + int chainLength = 1; + if (hasLeft) chainLength++; + if (hasRight) chainLength++; + + // 检查是否有隔张(如5,7的情况) + boolean hasGap = (hasLeft2 && !hasLeft) || (hasRight2 && !hasRight); + + if (chainLength >= 2) { + return hasGap ? 3 : 5; // 有嵌张的二连牌保护稍弱 + } + } + + // 检查嵌张情况(如4,6,缺5) + if (hasLeft2 || hasRight2) { + return 2; // 嵌张结构,少量保护 + } + + return 0; // 完全没有连牌关系 } // 从二维数组counts还原到手牌列表 @@ -510,6 +941,34 @@ public class ChangshaWinSplitCard { } public static List checktingpai(List cardhand) { + // 自动检测红中数量并从手牌中移除 + int hongZhongCount = 0; + List handWithoutHZ = new ArrayList<>(); + for (Integer card : cardhand) { + if (card == 501) { + hongZhongCount++; + } else { + handWithoutHZ.add(card); + } + } + return checktingpaiWithWildcard(handWithoutHZ, hongZhongCount, 0); + } + + /** + * 河池麻将听牌检测(支持红中万能牌) + * cardhand中不含红中,hongZhongCount为红中数量 + * 返回:打出哪些牌后可以听牌 + */ + /** + * 河池麻将听牌检测(支持红中万能牌 + 吃碰杠组) + * cardhand中不含红中,hongZhongCount为红中数量 + * 返回:打出哪些牌后可以听牌 + * + * 【核心修复】支持任意牌数的听牌检测 + * 原bug:只检测afterDiscardTotal==13/14的情况,导致杠后(11张)等场景无法识别听牌 + * 修复后:只要打出后剩余牌数符合"听牌特征"(N%3==2),就进行检测 + */ + public static List checktingpaiWithWildcard(List cardhand, int hongZhongCount, int committedCardCount) { //添加参数验证 if (cardhand == null || cardhand.isEmpty()) { return new ArrayList<>(); @@ -518,20 +977,62 @@ public class ChangshaWinSplitCard { List tpcards = new ArrayList<>(); List tmphc = new ArrayList<>(cardhand); - //缓存可能的胡牌列表 + //缓存可能的胡牌列表(不含501红中,红中通过wildCardCount传入) List candidateCards = new ArrayList<>(); for (int j = 101; j <= 109; j++) candidateCards.add(j); for (int j = 201; j <= 209; j++) candidateCards.add(j); for (int j = 301; j <= 309; j++) candidateCards.add(j); for (int j = 401; j <= 404; j++) candidateCards.add(j); - for (int j = 501; j <= 503; j++) candidateCards.add(j); + for (int j = 502; j <= 503; j++) candidateCards.add(j); + // 注意:501(红中)不作为候选牌,因为红中是万能牌不会被打出 + + // 关键修复:totalCards必须包含已吃碰杠的牌数 + // 胡牌需要14张:手牌 + 红中 + 吃碰杠组 + 候选牌(1) = 14 + int totalCards = cardhand.size() + hongZhongCount + committedCardCount; for (int i = 0; i < cardhand.size(); i++) { Integer tmpcard = tmphc.remove(0); //使用泛型避免强制转换 - //批量检查候选牌 + // 移除1张牌后的总牌数 + int afterDiscardTotal = (cardhand.size() - 1) + hongZhongCount + committedCardCount; + + // 【核心修复】支持任意牌数的听牌检测 + // 原逻辑:只检测 13 或 14 张(标准手牌) + // 修复后:检测所有符合"听牌特征"的牌数(N%3==2) + // 合法情况: + // - 10张(杠后2次):10%3=1 → 不听(差1手) + // - 11张(杠后1次):11%3=2 → 可以听! + // - 12张(碰后或吃后):12%3=0 → 不听(差1手) + // - 13张(正常摸牌):13%3=1 → 不听?不对,13张应该能听! + // + // 实际上听牌的特征应该是: + // - 总牌数14张时,手牌+红中=13张(听牌)→ 13%3=1 + // - 总牌数14张时,手牌+红中=12张(未听)→ 12%3=0 + // - 杠后11张:11-1=10张在手 → 需要再来4张才能胡(差2手),但可能已经听牌? + // + // 【修正理解】:听牌是指"再摸一张就能胡"的状态 + // 所以关键是:当前手牌+红中能否通过加一张任意牌而胡牌 + // 这个判断由 WinCard.try_win() 完成,不需要限制牌数! + // + // 简化逻辑:只要移除一张牌后,就尝试检测能否听牌 + // 让 WinCard 算法自己判断是否能胡 + + // 【核心修复】支持任意牌数的听牌检测(完全移除牌数限制!) + // 原bug:只检测13或14张的听牌情况,导致杠后(11张)、碰后(12张)等场景无法识别 + // + // 听牌的本质定义:打出一张牌后,剩余手牌+红中+任意一张候选牌 能否组成合法胡牌牌型 + // 这个判断由 WinCard.tryWin() 算法完成,它本身就能处理各种牌数 + // + // 【重要】不再做任何人为的牌数限制!支持所有可能的场景: + // 标准手牌13张(正常摸牌后) + // 杠后11张(开杠补牌后) + // 碰后12张(碰牌后摸一张) + // 吃后各种牌数 + // 甚至极端情况(多次杠/碰/吃后的特殊状态) + + // 直接进行听牌检测,让算法自己判断是否能胡 for (Integer candidate : candidateCards) { - WinCard win = new WinCard(tmphc, candidate); + WinCard win = new WinCard(tmphc, candidate, hongZhongCount, 501); if (win.tryWin() && !tpcards.contains(tmpcard)) { tpcards.add(tmpcard); break;//找到一个就可以跳出循环 @@ -543,16 +1044,37 @@ public class ChangshaWinSplitCard { return tpcards; } - // 分析最优出牌 + // 分析最优出牌(自动检测红中,不含吃碰杠信息) public static List analyzeBestDiscard(List cardInHand) { + // 自动检测红中 + int hongZhongCount = 0; + List handWithoutHZ = new ArrayList<>(); + for (Integer card : cardInHand) { + if (card == 501) { + hongZhongCount++; + } else { + handWithoutHZ.add(card); + } + } + // 无吃碰杠组时committedCardCount=0,手牌应13或14张 + return analyzeBestDiscardWithWildcard(handWithoutHZ, hongZhongCount, 0); + } + + /** + * 河池麻将分析最优出牌(支持红中万能牌 + 吃碰杠组) + * @param cardInHand 不含红中的手牌列表 + * @param hongZhongCount 红中数量 + * @param committedCardCount 已在吃碰杠组中的牌数 + */ + public static List analyzeBestDiscardWithWildcard(List cardInHand, int hongZhongCount, int committedCardCount) { if (cardInHand == null || cardInHand.isEmpty()) { return new ArrayList<>(); } // 听牌 // 返回要打的牌,打后可以听牌 - List checktingpai = checktingpai(cardInHand); - System.out.println("打出这种牌后可以听牌 " + checktingpai); + List checktingpaiResult = checktingpaiWithWildcard(cardInHand, hongZhongCount, committedCardCount); + // System.out.println("打出这种牌后可以听牌 " + checktingpaiResult + "(红中数量:" + hongZhongCount + ", 已吃碰杠牌数:" + committedCardCount + ")"); //已精简 Map map = new HashMap<>(); Map> jmap = new HashMap<>();// 对将map checkNormalHu(cardInHand, map); @@ -565,28 +1087,493 @@ public class ChangshaWinSplitCard { // 返回要打的牌,打后可以听牌 List zuizhongchupai = new ArrayList<>(); - + + //诊断日志:输出checktingpaiResult和suggested,方便排查出牌决策问题 + if (!checktingpaiResult.isEmpty() || !suggested.isEmpty()) { + System.out.println("[出牌决策诊断] checktingpaiResult(能听)=" + checktingpaiResult + ", suggested(推荐)=" + suggested); + } + //提取交集逻辑为独立方法 - findIntersectionCards(checktingpai, suggested, zuizhongchupai); + findIntersectionCards(checktingpaiResult, suggested, zuizhongchupai); + + // ===== 新增:孤张优先保护(听牌优先版)===== + // 当交集结果只包含对子类型的牌时,检查suggested中是否有非对子的孤张可以作为更优替代 + // 核心原则:听牌 > 保对子!只有在不牺牲听牌的前提下才保对子 + if (!zuizhongchupai.isEmpty() && !suggested.isEmpty()) { + boolean allCandidatesArePairs = true; + Map handCardCounts = new HashMap<>(); + for (int c : cardInHand) { + handCardCounts.put(c, handCardCounts.getOrDefault(c, 0) + 1); + } + for (Integer candidate : zuizhongchupai) { + int count = handCardCounts.getOrDefault(candidate, 0); + if (count < 2) { // 孤张或单张 + allCandidatesArePairs = false; + break; + } + } + if (allCandidatesArePairs) { + // 所有候选都是对子 → 在suggested中找非对子孤张作为替代 + + // 关键判断:如果当前候选是唯一的听牌选项,不能为了保对子而放弃听牌 + boolean isOnlyTingOption = (zuizhongchupai.size() == 1) + && checktingpaiResult.contains(zuizhongchupai.get(0)) + && checktingpaiResult.size() == 1; + + if (!isOnlyTingOption) { + // 不是唯一听牌选项 → 可以安全地寻找孤张替代 + Integer nonPairAlternative = null; + + // 第一优先:找既能听牌又是孤张的(最优) + for (int s : suggested) { + int sCount = handCardCounts.getOrDefault(s, 0); + if (sCount < 2 && checktingpaiResult.contains(s)) { + nonPairAlternative = s; + break; + } + } + + // 第二优先:如果孤张都不能听牌,但checktingpaiResult有多个选项(说明拆其他对子也能听),则找任意孤张 + if (nonPairAlternative == null && checktingpaiResult.size() > 1) { + for (int s : suggested) { + int sCount = handCardCounts.getOrDefault(s, 0); + if (sCount < 2) { + nonPairAlternative = s; + break; + } + } + } + + if (nonPairAlternative != null) { + boolean canStillTing = checktingpaiResult.contains(nonPairAlternative); + System.out.println("【孤张优先保护】原候选全是对子" + zuizhongchupai + + ",改选孤张" + nonPairAlternative + "以保留对子结构" + + (canStillTing ? "(仍能听牌✓)" : "(️牺牲听牌)")); + zuizhongchupai.clear(); + zuizhongchupai.add(nonPairAlternative); + } + } else { + // 是唯一听牌选项 → 必须保留,放弃孤张优先保护 + System.out.println("【孤张优先保护跳过】" + zuizhongchupai + + "是唯一听牌选项,听牌优先于保对子"); + } + } + } + // ===== 结束孤张优先保护 ===== // 推荐打几个牌都能听牌,需要判断打哪个听的牌更多 - if (checktingpai.size() > 1 && zuizhongchupai.isEmpty()) { - zuizhongchupai.addAll(checktingpai); + if (checktingpaiResult.size() > 1 && zuizhongchupai.isEmpty()) { + zuizhongchupai.addAll(checktingpaiResult); } + + // ===== 【新增】听牌候选优化:优先打出有多余张数的牌 ===== + // 核心思想:如果打A能听牌且手中有Ax2,打掉一张还剩一对 → 比只打一张更优 + // 因为保留对子可以增加未来碰牌/刻子机会 + if (zuizhongchupai.size() > 1) { + Map handCardCounts = new HashMap<>(); + for (int c : cardInHand) { + handCardCounts.put(c, handCardCounts.getOrDefault(c, 0) + 1); + } + + // 按"手牌数量"降序排列(多的排前面,优先打出) + zuizhongchupai.sort((a, b) -> { + int countA = handCardCounts.getOrDefault(a, 0); + int countB = handCardCounts.getOrDefault(b, 0); + // 先按数量降序(多的优先) + if (countA != countB) return Integer.compare(countB, countA); + // 数量相同则按牌值升序 + return Integer.compare(a, b); + }); + + // 只保留排序后的第一个(最优选择),避免后续逻辑在多个能听牌中随机选 + Integer bestTingChoice = zuizhongchupai.get(0); + int bestCount = handCardCounts.getOrDefault(bestTingChoice, 0); + if (bestCount >= 2 && zuizhongchupai.size() > 1) { + zuizhongchupai.clear(); + zuizhongchupai.add(bestTingChoice); + } + } else if (zuizhongchupai.size() == 1) { + // 单个听牌候选:也检查是否应该从完整能听列表中找更优的 + Map handCardCounts = new HashMap<>(); + for (int c : cardInHand) { + handCardCounts.put(c, handCardCounts.getOrDefault(c, 0) + 1); + } + + Integer currentChoice = zuizhongchupai.get(0); + int currentCount = handCardCounts.getOrDefault(currentChoice, 0); + + // 如果当前选择只有1张,但能听列表中有>=2张的牌 → 替换 + if (currentCount == 1 && checktingpaiResult.size() > 1) { + for (int tingCard : checktingpaiResult) { + if (tingCard == currentChoice) { + continue; + } + int tingCount = handCardCounts.getOrDefault(tingCard, 0); + if (tingCount >= 2) { + zuizhongchupai.clear(); + zuizhongchupai.add(tingCard); + break; + } + } + } + } + // ===== 结束听牌候选优化 ===== // 如果判断听牌和递归出来的牌不一样 优先以听牌为准 if (zuizhongchupai.isEmpty()) { - zuizhongchupai.addAll(checktingpai); + // 反拆对子保护:当唯一的听牌候选需要拆对子时,评估是否应该保留对子 + if (checktingpaiResult.size() == 1 && !suggested.isEmpty()) { + Integer tingCandidate = checktingpaiResult.get(0); + int tingCardCount = 0; + for (int c : cardInHand) { + if (c == tingCandidate) { + tingCardCount++; + } + } + // 如果听牌候选是对子的一部分(count>=2),检查是否有更优的不拆对子选择 + if (tingCardCount >= 2) { + // 检查是否是刚摸到的牌形成的新对子——这种情况下更要保护不拆 + boolean isJustDrawnPair = false; + if (ChangShaSuanFaTest.drawnCards != 0 && tingCandidate == ChangShaSuanFaTest.drawnCards) { + isJustDrawnPair = true; + // System.out.println("反拆对子-摸牌检测:听牌候选" + tingCandidate + "是刚摸到的牌,且形成对子(count=" + tingCardCount + "),高优先级保护"); //已精简 + } + + // 在suggested中找非对子的候选 + Integer safeAlternative = null; + for (int s : suggested) { + int sCount = 0; + for (int c : cardInHand) { + if (c == s) sCount++; + } + if (sCount < 2) { // 非对子牌,可以安全打出 + safeAlternative = s; + break; + } + } + if (safeAlternative != null) { + // System.out.println("反拆对子保护:听牌候选" + tingCandidate + "是对子的一部分( count=" + tingCardCount + + // "),为保留对子结构,改选非对子出牌" + safeAlternative + "(可能不听牌但保留将牌来源)"); //已精简 + zuizhongchupai.add(safeAlternative); + // System.out.println("zuizhongchupai" + zuizhongchupai); //已精简 + return zuizhongchupai; + } else if (isJustDrawnPair) { + // 刚摸到的牌形成对子,且suggested中无非对子选项 + // 从suggested中选一个"破坏性最小"的对子来打(优先非将牌候选) + // System.out.println("反拆对子保护:听牌候选" + tingCandidate + "是刚摸到形成的对子,但suggested中无非对子选项"); //已精简 + // 此时仍然使用原逻辑,让后续流程决定 + } else { + // System.out.println("反拆对子保护检查:听牌候选" + tingCandidate + "是对子,但suggested中无非对子选项,仍使用听牌候选"); //已精简 + } + } + } + zuizhongchupai.addAll(checktingpaiResult); } // 如果判断最后打出牌还未听牌,就用递归判断的牌 if (zuizhongchupai.isEmpty()) { + // ===== 唯将对兜底保护 ===== + // 当suggested全部是仅有的将牌来源(对子)时,避免拆掉唯一的将 + if (!suggested.isEmpty() && isAllCandidatesOnlyJiangSource(suggested, cardInHand)) { + System.out.println("唯将对兜底保护:suggested候选 " + suggested + " 全部是手牌中仅有的将牌来源(对子),尝试寻找非对子替代"); + // 尝试从手牌中找一张非对子的牌作为替代(即使效率较低) + Integer nonPairAlternative = findNonPairAlternative(cardInHand, suggested); + if (nonPairAlternative != null) { + zuizhongchupai.add(nonPairAlternative); + // System.out.println("唯将对保护生效:改选非对子出牌 " + nonPairAlternative + " 以保留将牌来源"); //已精简 + // System.out.println("zuizhongchupai" + zuizhongchupai); //已精简 + return zuizhongchupai; + } else { + System.out.println("唯将对保护:手牌中无非对子替代牌,仍使用原建议(可能拆将)"); + } + } + // ===== 结束唯将对兜底保护 ===== zuizhongchupai.addAll(suggested); } - System.out.println("zuizhongchupai" + zuizhongchupai); + + // ===== 新增:听牌面广度比较(关键优化!)===== + // 问题场景:suggested基于"孤立度"推荐可能不是最优听牌选择 + // 例如:打三饼听3张 vs 打六饼只听2张,应该选打三饼 + if (!zuizhongchupai.isEmpty() && checktingpaiResult.size() > 1) { + // 检查是否有被过滤掉的有效听牌候选 + boolean hasFilteredCandidates = false; + for (Integer card : checktingpaiResult) { + if (!zuizhongchupai.contains(card)) { + hasFilteredCandidates = true; + break; + } + } + + if (hasFilteredCandidates) { + // System.out.println("【听牌面广度比较】检测到有候选被过滤: suggested=" + zuizhongchupai + // + ", 全部听牌候选=" + checktingpaiResult + ", 启动全面比较..."); //已精简 + + Map> tingPaiMap = ChangShaSuanFaTest.quyizhangTingPai(cardInHand); + int bestTingNum = -1; + Integer bestCard = null; + + for (Integer candidate : checktingpaiResult) { + // 【核心优化】将牌保护改为智能判断(原逻辑过于激进) + // 原逻辑:只要破坏对子就跳过 + // 新逻辑:只有当"破坏唯一对子"或"当前有非对子候选可用"时才跳过 + boolean shouldSkip = false; + + if (ChangShaSuanFaTest.isDiscardBreakingLastPair(candidate, cardInHand)) { + // 会破坏最后一个对子 → 检查是否有其他非对子的替代方案 + // 如果当前候选全是对子,且这是唯一的听牌机会,则不跳过(听牌优先) + boolean allCurrentCandidatesArePairs = true; + for (Integer c : zuizhongchupai) { + int cCount = 0; + for (Integer handCard : cardInHand) { + if (c.equals(handCard)) cCount++; + } + if (cCount < 2) { + allCurrentCandidatesArePairs = false; + break; + } + } + + if (allCurrentCandidatesArePairs) { + // 当前候选全部是对子 → 允许拆对一个来听牌(听牌优先原则) + System.out.println("【听牌优先】允许拆对子" + candidate + + "(当前候选全是对子,听牌优先于保对子)"); + shouldSkip = false; // 不跳过!允许拆对子听牌 + } else { + // 有非对子候选可用 → 保护对子不拆 + shouldSkip = true; + } + } + + if (shouldSkip) { + // System.out.println(" 听牌面比较:跳过" + candidate + "(保护对子)"); //已精简 + continue; + } + if (tingPaiMap.containsKey(candidate)) { + // 计算听牌数量:直接使用听牌列表的大小 + List tingCards = tingPaiMap.get(candidate); + int tingNum = tingCards != null ? tingCards.size() : 0; + // System.out.println(" 听牌面比较:打" + candidate + "可听" + tingNum + "张(" + tingPaiMap.get(candidate) + ")"); //已精简 + if (tingNum > bestTingNum) { + bestTingNum = tingNum; + bestCard = candidate; + } + } + } + + if (bestCard != null && !bestCard.equals(zuizhongchupai.get(0))) { + // System.out.println("【听牌面广度比较结果】原选择" + zuizhongchupai.get(0) + // + " → 改选" + bestCard + "(听" + bestTingNum + "张最多)"); //已精简 + zuizhongchupai.clear(); + zuizhongchupai.add(bestCard); + } else if (bestCard != null) { + // System.out.println("【听牌面广度比较结果】确认" + bestCard + "已是最优选择(听" + bestTingNum + "张)"); //已精简 + } + } + } + // ===== 结束听牌面广度比较 ===== + + // ===== 新增:最终出牌候选智能排序 ===== + // 当有多个候选时,按"孤张优先级"重新排序:绝对孤张 > 弱连接孤张 > 靠章牌 + if (zuizhongchupai.size() > 1) { + List sortedCandidates = sortCandidatesByIsolation(zuizhongchupai, cardInHand); + if (!sortedCandidates.isEmpty()) { + zuizhongchupai.clear(); + zuizhongchupai.addAll(sortedCandidates); + // System.out.println("候选牌智能排序(孤张优先): " + zuizhongchupai); //已精简 + } + } + // ===== 结束智能排序 ===== + + // ===== 新增:绝对孤项单张优先(优化四万vs七饼问题)===== + // 场景:suggested返回的候选是对子,但手牌中存在"绝对孤项单张" + // 判断标准:该牌(1)只有1张 (2)在同花色±2范围内无其他牌 (3)非风箭牌 + // 原则:打掉绝对孤项单张 > 拆散对子(因为对子至少可以做将或碰) + if (!zuizhongchupai.isEmpty()) { + Integer candidate = zuizhongchupai.get(0); + int candidateCount = 0; + for (int c : cardInHand) { + if (c == candidate) candidateCount++; + } + + if (candidateCount >= 2) { // 当前候选是对子 + Integer absoluteIsolated = findAbsoluteIsolatedCard(cardInHand, candidate); + if (absoluteIsolated != null) { + System.out.println("【绝对孤项优先】原选" + candidate + "(对子×" + candidateCount + + ") → 改选" + absoluteIsolated + "(绝对孤项单张)"); + zuizhongchupai.clear(); + zuizhongchupai.add(absoluteIsolated); + } + } + } + // ===== 结束绝对孤项单张优先 ===== + + System.out.println("zuizhongchupai" + zuizhongchupai); //保留:最终候选关键输出 return zuizhongchupai.isEmpty() ? new ArrayList<>() : zuizhongchupai; } + /** + * 查找绝对孤项单张(比拆对子更应优先打出的牌) + * + * 适用场景: + * 手牌: [102,102,104,107,108,108,109,207,207,305,305,305,307,307] + * 算法推荐打207(七饼对),但104(四万)是绝对孤项,应该优先打104 + * + * @param handCards 当前手牌 + * @param excludeCard 要排除的当前候选牌 + * @return 绝对孤项牌,如果没有则返回null + */ + private static Integer findAbsoluteIsolatedCard(List handCards, Integer excludeCard) { + if (handCards == null || handCards.size() <= 1) { + return null; + } + + // 统计每张牌的数量 + Map counts = new HashMap<>(); + for (int card : handCards) { + counts.put(card, counts.getOrDefault(card, 0) + 1); + } + + for (int card : handCards) { + if (card == excludeCard) continue; // 跳过当前候选 + + int count = counts.getOrDefault(card, 0); + if (count > 1) continue; // 只考虑单张 + + int type = card / 100; + if (type >= 4) continue; // 跳过风箭牌(401-404, 501-503) + + // 检查连接度:同花色±2范围内是否有其他牌 + boolean hasConnection = false; + for (int other : handCards) { + if (other == card) continue; + if (other / 100 != type) continue; // 不同花色不比较 + int diff = Math.abs(other - card); + if (diff <= 2) { // 相邻或隔一张(可能形成顺子搭子) + hasConnection = true; + break; + } + } + + if (!hasConnection) { + return card; // 找到绝对孤项 + } + } + return null; + } + + /** + * 唯将对保护:检查候选牌列表中的每张牌是否都是手牌中"仅有的将牌来源" + * 即:手牌中只有这些对子,打出任何一个都会导致无将可用 + */ + private static boolean isAllCandidatesOnlyJiangSource(List candidates, List cardInHand) { + if (candidates == null || candidates.isEmpty() || cardInHand == null || cardInHand.isEmpty()) { + return false; + } + + // 统计手牌中每种牌的数量 + Map handCounts = new HashMap<>(); + for (int c : cardInHand) { + handCounts.put(c, handCounts.getOrDefault(c, 0) + 1); + } + + // 统计手牌中有多少个对子(count >= 2) + int totalPairs = 0; + for (int count : handCounts.values()) { + if (count >= 2) totalPairs++; + } + + // 如果手牌中只有一个对子,且candidates包含这个对子,则返回true + if (totalPairs <= 1) { + for (int cand : candidates) { + int count = handCounts.getOrDefault(cand, 0); + if (count >= 2) { + return true; // 唯一对子被列入候选,需要保护 + } + } + } + + // 如果手牌中有多个对子,但candidates全是对子牌,也检查是否需要保护 + boolean allCandidatesArePairs = true; + for (int cand : candidates) { + int count = handCounts.getOrDefault(cand, 0); + if (count < 2) { + allCandidatesArePairs = false; + break; + } + } + + // 如果所有候选都是对子,且手牌中对子数量<=2,也需要保护 + if (allCandidatesArePairs && totalPairs <= 2) { + return true; + } + + return false; + } + + /** + * 从手牌中找一张非对子的替代出牌 + * 优先级:风牌 > 孤张(无靠章) > 其他非对子 + */ + private static Integer findNonPairAlternative(List cardInHand, List excludeCards) { + if (cardInHand == null || cardInHand.isEmpty()) { + return null; + } + + Set excludeSet = new HashSet<>(excludeCards); + + // 统计手牌中每张牌的数量 + Map handCounts = new HashMap<>(); + for (int c : cardInHand) { + handCounts.put(c, handCounts.getOrDefault(c, 0) + 1); + } + + // 第一优先级:风字牌(401-404, 501-503)中的非对子 + for (int c : cardInHand) { + if (excludeSet.contains(c)) continue; + int type = c / 100; + if ((type == 4 || type == 5) && handCounts.getOrDefault(c, 0) == 1) { + return c; + } + } + + // 第二优先级:万筒条中的孤张(无靠章) + Set connected = findConnectedCards(cardInHand); + for (int c : cardInHand) { + if (excludeSet.contains(c)) continue; + int type = c / 100; + if (type >= 1 && type <= 3 && handCounts.getOrDefault(c, 0) == 1 && !connected.contains(c)) { + return c; + } + } + + // 第三优先级:任意非对子牌 + for (int c : cardInHand) { + if (excludeSet.contains(c)) continue; + if (handCounts.getOrDefault(c, 0) == 1) { + return c; + } + } + + return null; + } + + /** 找出手牌中与其他牌有靠章关系的牌集合 */ + private static Set findConnectedCards(List cards) { + Set connected = new HashSet<>(); + for (int i = 0; i < cards.size(); i++) { + for (int j = 0; j < cards.size(); j++) { + if (i != j) { + int ci = cards.get(i), cj = cards.get(j); + if (ci / 100 == cj / 100 && Math.abs(ci - cj) <= 2) { + connected.add(ci); + break; + } + } + } + } + return connected; + } + public static Integer selectBestCardRemove258(List cards) { if (cards == null || cards.isEmpty()) { return null; @@ -616,12 +1603,12 @@ public class ChangshaWinSplitCard { // 优先从非258牌中选 if (!non258Cards.isEmpty()) { - return selectBestSingleCard(non258Cards); + return selectBestSingleCard(non258Cards, null); } // 如果没有非258牌,从258牌中选 if (!is258Cards.isEmpty()) { - return selectBestSingleCard(is258Cards); + return selectBestSingleCard(is258Cards, null); } return null; @@ -699,26 +1686,1416 @@ public class ChangshaWinSplitCard { } } + // ==================== 优化1:多轮模拟前瞻系统(Monte Carlo简化版) ==================== + + /** + * 智能决策引擎:通过多轮模拟评估每张候选出牌的未来价值 + * + * 核心思想: + * 对每张候选牌,模拟"打出→摸未知牌→再打→摸未知牌"的N轮过程, + * 评估每种路径最终能达到的状态(听牌/听牌面广度/差手数), + * 选择期望值最高的出牌。 + * + * @param candidates 候选出牌列表 + * @param cardInHand 当前手牌(不含红中) + * @param hongZhongCount 红中数量 + * @param committedCardCount 已吃碰杠的牌数 + * @param outCards 已打出的牌列表(用于排除) + * @return 最优出牌 + */ + public static Integer selectByMultiRoundSimulation( + List candidates, + List cardInHand, + int hongZhongCount, + int committedCardCount, + List outCards) { + + if (candidates == null || candidates.isEmpty()) return null; + if (candidates.size() == 1) return candidates.get(0); + + // System.out.println("\n【多轮前瞻】开始智能决策,候选数:" + candidates.size() + ", 模拟深度:3轮"); //已精简 + + Map candidateScores = new LinkedHashMap<>(); + + for (Integer candidate : candidates) { + double score = simulateMultiRound(candidate, cardInHand, hongZhongCount, committedCardCount, outCards); + candidateScores.put(candidate, score); + // System.out.println(" 候选牌 " + candidate + " 综合评分: " + String.format("%.2f", score)); //已精简 + } + + // 找最高分的牌 + Integer bestCard = null; + double bestScore = Double.NEGATIVE_INFINITY; + for (Map.Entry entry : candidateScores.entrySet()) { + if (entry.getValue() > bestScore) { + bestScore = entry.getValue(); + bestCard = entry.getKey(); + } + } + + if (bestCard != null && !bestCard.equals(candidates.get(0))) { + // System.out.println("【多轮前瞻结果】原选择 " + candidates.get(0) + " → 智能选择 " + bestCard + + // "(评分: " + String.format("%.2f", bestScore) + ")"); //已精简 + } else if (bestCard != null) { + // System.out.println("【多轮前瞻结果】确认 " + bestCard + " 已是最优(评分: " + String.format("%.2f", bestScore) + ")"); //已精简 + } + + return bestCard != null ? bestCard : candidates.get(0); + } + + /** + * 执行多轮蒙特卡洛模拟 + * @param discardCandidate 要测试的出牌 + * @param hand 手牌 + * @param hz 红中数量 + * @param committed 已吃碰杠牌数 + * @param outCards 已出的牌 + * @return 期望得分(越高越好,范围-100~100) + */ + private static double simulateMultiRound( + Integer discardCandidate, + List hand, + int hz, + int committed, + List outCards) { + + // ===== 第1步:模拟打出这张牌后的状态 ===== + List simHand = new ArrayList<>(hand); + boolean removed = simHand.remove(Integer.valueOf(discardCandidate)); + if (!removed) { + return -999; // 无法移除该牌,直接给最低分 + } + + // 检测当前状态 + List currentTing = checktingpaiWithWildcard(simHand, hz, committed); + if (!currentTing.isEmpty()) { + // 已经听牌!这是非常好的状态 + // 计算听牌面质量 + double tingQuality = calculateTingQuality(simHand, hz, committed, outCards); + return 80 + tingQuality; // 听牌基础分很高 + } + + // 未听牌,需要继续模拟 + Map stateMap = new HashMap<>(); + checkNormalHu(simHand, stateMap); + int remainingMelds = Integer.parseInt(stateMap.getOrDefault("remainingMelds", "99").toString()); + List residuals = (List) stateMap.getOrDefault("cardResiue", new ArrayList<>()); + + if (remainingMelds <= 1 && residuals.size() <= 2) { + // 接近听牌状态,给予较高分 + return 60 + (3 - remainingMelds) * 10 - residuals.size() * 2; + } + + // ===== 第2步:模拟摸一张可能的牌(采样关键牌)===== + Set usefulCards = generateUsefulDrawCandidates(simHand, outCards); + if (usefulCards.isEmpty()) { + // 如果没有有用牌可摸,基于当前状态评分 + return evaluateHandState(simHand, hz, committed, remainingMelds, residuals); + } + + double totalFutureScore = 0; + int simulationCount = 0; + + // 为了性能控制,最多采样15张最有价值的牌来模拟 + List sampledCards = new ArrayList<>(usefulCards); + if (sampledCards.size() > 15) { + sampledCards = prioritizeSampleCards(sampledCards, simHand, 15); + } + + for (Integer drawnCard : sampledCards) { + // 模拟摸牌 + List round2Hand = new ArrayList<>(simHand); + round2Hand.add(drawnCard); + + // 第2轮:检测能否听牌 + List round2Ting = checktingpaiWithWildcard(round2Hand, hz, committed); + + if (!round2Ting.isEmpty()) { + // 摸后能听牌!计算这条路径的价值 + double pathValue = 50 + calculateTingQuality(round2Hand, hz, committed, outCards) * 0.8; + + // 如果摸到的牌正好是"改进型"(让手牌结构变好),额外加分 + double improvementBonus = evaluateImprovement(drawnCard, simHand, outCards); + pathValue += improvementBonus * 10; + + totalFutureScore += pathValue; + } else { + // 摸了还不能听,继续第3轮模拟(简化版:只做一次快速评估) + double deepScore = simulateRound3Quick(round2Hand, hz, committed, outCards); + totalFutureScore += deepScore; + } + + simulationCount++; + } + + // 计算平均期望分 + double avgFutureScore = simulationCount > 0 ? totalFutureScore / simulationCount : 0; + + // 结合当前状态的评分 + double currentStateScore = evaluateHandState(simHand, hz, committed, remainingMelds, residuals); + + // 最终得分 = 当前状态分 * 0.4 + 未来期望分 * 0.6 + final double result = currentStateScore * 0.4 + avgFutureScore * 0.6; + + // ===== 【优化5】连张完整性保护调整 ===== + // 防止算法错误拆除高价值连张(如4+张同花色连续牌) + // 通过多层前置条件确保不会影响听牌决策 + double protectedResult = applyConsecutiveProtection( + discardCandidate, hand, result, hz, committed, outCards, null); + + return protectedResult; + } + + /** + * 第3轮快速评估(性能优化:不做完整递归,只做启发式评分) + */ + private static double simulateRound3Quick(List hand, int hz, int committed, List outCards) { + Map map = new HashMap<>(); + checkNormalHu(hand, map); + int melds = Integer.parseInt(map.getOrDefault("remainingMelds", "99").toString()); + + if (melds <= 1) return 30; // 再摸一轮很可能听牌 + if (melds == 2) return 15; // 还差两手 + return 5 - melds * 2; // 还很远,低分 + } + + // ==================== 优化5:连张完整性保护模块 ==================== + + /** + * 【全局配置开关】连张完整性保护功能 + * + * 功能说明: + * 防止算法错误地拆除高价值的连续牌(如4张以上同花色连张) + * 核心原则: 完整的4+连张整体价值 > 拆除后的分散价值 + * + * 典型场景(Round4): + * 手牌: [102,103,104,105,106]万子5连张 + [306,308]条子嵌张 + * 错误决策: 打102(拆散5连张) → 剩余结构断裂 + * 正确决策: 打306/308(清理烂牌) → 保留5连张完整性 + * + * 设计原则: 使用"软约束"(评分调整)而非"硬禁止"(拦截) + * 通过多层前置条件过滤危险场景,确保不会影响正常听牌 + * + * @since 2026-07-10 修复Round4次优决策问题 + */ + public static final boolean ENABLE_CONSECUTIVE_PROTECTION = true; + + /** + * 连张保护的最小长度阈值 + * 只有>=此值的连张才触发保护(3张=普通顺子,不特殊处理) + */ + private static final int CONSECUTIVE_MIN_LENGTH = 4; + + /** + * 【核心函数】连张完整性评分调整器 + * + * 对"拆除高价值连张"的候选牌进行降分惩罚, + * 引导算法优先选择其他更差的孤张/烂牌。 + * + * @param candidate 待评估的出牌候选 + * @param hand 当前手牌(不含红中) + * @param originalScore 模拟引擎给出的原始评分 + * @param hongZhongCount 红中数量(万能牌) + * @param committedCount 已吃碰杠的牌数 + * @param outCards 已打出的所有牌(用于计算剩余牌墙) + * @param chowCards 吃牌记录(用于门子限制检查) + * @return 调整后的评分(可能低于原始分) + */ + protected static double applyConsecutiveProtection( + Integer candidate, + List hand, + double originalScore, + int hongZhongCount, + int committedCount, + List outCards, + List chowCards) { + + // ===== 全局开关检查 ===== + if (!ENABLE_CONSECUTIVE_PROTECTION) { + return originalScore; + } + + // ===== 第0层:前置条件过滤(完全豁免场景)===== + + // 0.1 花色合法性检查:只有万筒条才可能有顺子/连张 + int suit = candidate / 100; + if (suit < 1 || suit > 3) { + return originalScore; // 风字牌/箭牌不可能形成连张 + } + + // 0.2 【最高优先级】听牌检测:如果打这张能听牌→完全不保护! + List testHand = new ArrayList<>(hand); + boolean removed = testHand.remove(Integer.valueOf(candidate)); + if (!removed) { + return originalScore; // 牌不在手牌中,异常情况 + } + + try { + List tingIfDiscard = checktingpaiWithWildcard( + testHand, hongZhongCount, committedCount); + + if (!tingIfDiscard.isEmpty()) { + // 能听牌! 连张保护让路! 听牌是最高优先级 + // System.out.println(String.format( + // "[连张保护豁免] %d能听牌(%s),跳过连张保护", + // candidate, tingIfDiscard)); + return originalScore; + } + } catch (Exception e) { + // 听牌计算失败时不阻塞,继续后续逻辑 + } + + // 0.3 特殊牌型检测:碰碰胡/七对不需要顺子,连张可自由拆除 + if (hasSpecialPatternPotential(hand)) { + return originalScore; + } + + // 0.4 门子限制检测(河池麻将特有):受限花色不保护 + if (isSuitRestrictedByChow(chowCards, suit)) { + return originalScore; + } + + // ===== 第1层:连张识别与价值评估 ===== + + // 检测该候选牌是否属于"真正的连续序列" + ConsecutiveInfo consecutiveInfo = findConsecutiveSequenceIncluding(hand, candidate); + + if (consecutiveInfo == null || consecutiveInfo.length < CONSECUTIVE_MIN_LENGTH) { + return originalScore; // 不是长连张的一部分,无需保护 + } + + // ===== 第2层:游戏阶段动态调整(终局衰减)===== + + double phaseFactor = calculateGamePhaseFactor(outCards, committedCount); + + if (phaseFactor < 0.1) { + return originalScore; // 终局(<10%牌墙)完全不保护,快速听牌优先 + } + + // ===== 第3层:多花色冲突处理(只保最优组)===== + + // 如果手牌有多个花色都有>=4连张,只保护最长的那一组 + Map allSuitMaxLengths = new HashMap<>(); + for (int s = 1; s <= 3; s++) { + int maxLen = findMaxConsecutiveLengthInSuit(hand, s); + if (maxLen >= CONSECUTIVE_MIN_LENGTH) { + allSuitMaxLengths.put(s, maxLen); + } + } + + if (allSuitMaxLengths.size() >= 2) { + // 多花色冲突:检查当前候选是否属于"最长的花色" + Integer bestSuit = Collections.max(allSuitMaxLengths.entrySet(), + Map.Entry.comparingByValue()).getKey(); + + if (suit == bestSuit) { + // 当前候选不是最优花色 → 大幅降低保护强度 + // (允许拆这组以保留更长的那组) + phaseFactor *= 0.3; + } else { + // 是最优花色 → 加强保护(因为另一组迟早要拆) + phaseFactor *= 1.2; + } + } + + // ===== 第4层:连张质量评估(边张 vs 中张)===== + + double qualityBonus = evaluateConsecutiveQuality(consecutiveInfo, hand); + + // ===== 第5层:替代品检测(是否有明显更差的选择)===== + + double alternativeBonus = 0; + Integer worstIsolated = findWorstIsolatedCardInHand(hand, candidate); + if (worstIsolated != null) { + // 有明显更差的选择存在 → 加强保护(引导去打那张烂牌) + alternativeBonus += 8.0; + + // 如果替代品是不同花色的孤张 → 进一步加强 + if ((worstIsolated / 100) != suit) { + alternativeBonus += 4.0; // 跨花色的孤张应该优先清理 + } + } + + // ===== 最终保护分数计算 ===== + + double protectionPenalty = calculateProtectionPenalty( + consecutiveInfo.length, phaseFactor, qualityBonus, alternativeBonus); + + double adjustedScore = originalScore - protectionPenalty; + + // 日志输出(仅在有实际降分时输出) + if (protectionPenalty > 0.1) { + System.out.println(String.format( + " [连张保护] %d属于%d花色%d连张[%d-%d], 降分%.1f(原%.1f→新%.1f) 阶段系数=%.2f", + candidate, suit, consecutiveInfo.length, + consecutiveInfo.startValue, consecutiveInfo.endValue, + protectionPenalty, originalScore, adjustedScore, + phaseFactor)); + } + + return adjustedScore; + } + + /** + * 计算连张保护的惩罚分数 + * + * @param length 连张长度 + * @param phaseFactor 游戏阶段系数(0~1.2) + * @param qualityBonus 连张质量加成 + * @param alternativeBonus 替代品加成 + * @return 应该扣除的分数 + */ + private static double calculateProtectionPenalty( + int length, double phaseFactor, double qualityBonus, double alternativeBonus) { + + // 基础保护分(随长度非线性增长) + double basePenalty; + switch (length) { + case 4: basePenalty = 8.0; break; // 4连张:轻度保护 + case 5: basePenalty = 15.0; break; // 5连张:强保护(Round4场景) + case 6: basePenalty = 22.0; break; // 6连张:极强保护(罕见) + default: basePenalty = 12.0 + (length - 4) * 5; break; // 更长:线性增长 + } + + // 组合计算 + double totalPenalty = (basePenalty + qualityBonus + alternativeBonus) * phaseFactor; + + // 上限控制(避免过度惩罚导致错过听牌机会) + return Math.min(totalPenalty, 30.0); + } + + /** + * 【辅助数据结构】连张信息 + */ + private static class ConsecutiveInfo { + int startValue; // 起始值(如2代表102) + int endValue; // 结束值(如6代表106) + int length; // 长度(如5代表5张) + int suit; // 花色(1=万,2=筒,3=条) + + ConsecutiveInfo(int suit, int start, int end, int len) { + this.suit = suit; + this.startValue = start; + this.endValue = end; + this.length = len; + } + } + + /** + * 检测某张牌所属的最大连续序列 + * + * 例如手牌[102,103,104,105,106]中的103会返回: + * ConsecutiveInfo{start=2, end=6, length=5, suit=1} + * + * @param hand 手牌 + * @param card 要检测的牌 + * @return 连张信息,如果不属于任何>=3的连张则返回null + */ + private static ConsecutiveInfo findConsecutiveSequenceIncluding(List hand, Integer card) { + int suit = card / 100; + int value = card % 100; + + if (suit < 1 || suit > 3) return null; // 非数值牌 + + // 收集该花色所有牌的值 + Set valuesInSuit = new HashSet<>(); + for (int c : hand) { + if (c / 100 == suit) { + valuesInSuit.add(c % 100); + } + } + + if (!valuesInSuit.contains(value)) return null; // 该花色没有这张牌 + + // 向左扩展找起点 + int start = value; + while (valuesInSuit.contains(start - 1) && start - 1 >= 1) { + start--; + } + + // 向右扩展找终点 + int end = value; + while (valuesInSuit.contains(end + 1) && end + 1 <= 9) { + end++; + } + + int length = end - start + 1; + + if (length < 3) return null; // 太短不算有意义连张 + + return new ConsecutiveInfo(suit, start, end, length); + } + + /** + * 找到指定花色的最大连张长度 + */ + private static int findMaxConsecutiveLengthInSuit(List hand, int suit) { + Set valuesInSuit = new HashSet<>(); + for (int c : hand) { + if (c / 100 == suit) { + valuesInSuit.add(c % 100); + } + } + + if (valuesInSuit.isEmpty()) return 0; + + int maxLength = 1; + int currentLength = 1; + int prev = 0; + + for (int v = 1; v <= 9; v++) { + if (valuesInSuit.contains(v)) { + if (prev > 0 && v == prev + 1) { + currentLength++; + maxLength = Math.max(maxLength, currentLength); + } else { + currentLength = 1; + } + prev = v; + } else { + prev = 0; + currentLength = 0; + } + } + + return maxLength; + } + + /** + * 评估连张质量(边张 vs 中张) + * + * 纯中张连张(如3-7)比含边张的连张(如1-4或6-9)更有价值 + * 因为中张进张面更广且不容易被卡住 + * + * @param info 连张信息 + * @param hand 手牌(用于统计对子等信息) + * @return 质量加成分(-2~+5) + */ + private static double evaluateConsecutiveQuality(ConsecutiveInfo info, List hand) { + double bonus = 0; + + // 因素1:中心程度(越靠近中心越好) + int center = (info.startValue + info.endValue) / 2; + if (center >= 4 && center <= 6) { + bonus += 3.0; // 中心连张(如34567) + } else if (center >= 3 && center <= 7) { + bonus += 1.5; // 偏中心(如23456或45678) + } else { + bonus -= 1.0; // 边缘连张(如12345或56789) + } + + // 因素2:完整程度(是否覆盖了关键中间位置) + if (info.startValue <= 3 && info.endValue >= 7) { + bonus += 2.0; // 覆盖了核心区域(3-7) + } + + // 因素3:对子情况(连张内如果有对子,轻微降低保护强度) + // 因为对子可以碰牌,可能有额外价值 + Map counts = new HashMap<>(); + for (int c : hand) { + if (c / 100 == info.suit && c % 100 >= info.startValue && c % 100 <= info.endValue) { + counts.put(c, counts.getOrDefault(c, 0) + 1); + } + } + + long pairCountInConsecutive = counts.values().stream().filter(c -> c >= 2).count(); + if (pairCountInConsecutive > 0) { + bonus -= 1.5 * pairCountInConsecutive; // 有对子可稍微降低保护 + } + + return Math.max(-2.0, Math.min(bonus, 5.0)); // 限制范围 + } + + /** + * 在手牌中寻找比候选牌更差的孤张/废牌 + * + * 用于判断是否存在"明显更好的选择",如果有则加强连张保护 + * + * 判断标准: + * 1. 不同花色的单张(跨花色孤张应优先清理) + * 2. 同花色但不属于任何有效搭子的牌 + * 3. 风字牌/箭牌(除非是对子) + * + * @param hand 手牌 + * @param exclude 排除的牌(通常是候选本身) + * @return 找到的最差牌,如果没有则返回null + */ + private static Integer findWorstIsolatedCardInHand(List hand, Integer exclude) { + Integer worstCard = null; + int worstScore = Integer.MAX_VALUE; + int excludeSuit = exclude / 100; + + // 统计每张牌的连接度(邻居数量) + Map connectionMap = new HashMap<>(); + for (int c : hand) { + if (c == exclude) continue; + + int connections = 0; + int suit = c / 100; + int value = c % 100; + + if (suit >= 1 && suit <= 3) { + // 数值牌:数邻居 + for (int other : hand) { + if (other == c || other == exclude) continue; + if (other / 100 == suit && Math.abs((other % 100) - value) <= 2) { + connections++; + } + } + } else { + // 风字牌/箭牌:只有相同牌才算连接(做对子/刻子) + for (int other : hand) { + if (other == c || other == exclude) continue; + if (other == c) { + connections += 3; // 相同牌权重高 + } + } + } + + connectionMap.put(c, connections); + } + + // 找连接度最低的(最孤立的) + for (Map.Entry entry : connectionMap.entrySet()) { + int card = entry.getKey(); + int conn = entry.getValue(); + int cardSuit = card / 100; + + int score = conn; + + // 跨花色的大幅加分(跨花色孤张是最该打的) + if (cardSuit != excludeSuit && conn == 0) { + score -= 10; // 强制降低分数 + } + + // 风字牌/箭牌单张加分(通常没用) + if (cardSuit >= 4 && conn == 0) { + score -= 5; + } + + if (score < worstScore) { + worstScore = score; + worstCard = card; + } + } + + // 只有当最差牌确实很孤立时才返回(避免误判) + if (worstCard != null && worstScore < 0) { + return worstCard; + } + + return null; + } + + /** + * 计算游戏阶段因子(用于动态调整保护强度) + * + * 规则: + * - 剩余>60张牌(早期): factor=1.0 完整保护 + * - 剩余40-60张(中期): factor=0.7-1.0 适度保护 + * - 剩余20-40张(后期): factor=0.3-0.7 弱化保护 + * - 剩余<20张(终局): factor<0.3 几乎不保护 + * + * @param outCards 已出的牌 + * @param committed 已吃碰杠的牌数 + * @return 阶段因子(0~1.2) + */ + private static double calculateGamePhaseFactor(List outCards, int committed) { + // 总牌数136张,减去已出的和已吃碰杠的 + int totalUsed = (outCards != null ? outCards.size() : 0) + committed * 3; + int remaining = 136 - totalUsed; + + if (remaining >= 60) return 1.0; // 早期 + if (remaining >= 40) return 0.85; // 中前期 + if (remaining >= 25) return 0.6; // 中后期 + if (remaining >= 15) return 0.35; // 后期 + if (remaining >= 8) return 0.15; // 终局前 + return 0.05; // 终局(几乎不保护) + } + + /** + * 检测手牌是否有特殊牌型潜力(碰碰胡/七对等不需要顺子的牌型) + * + * @param hand 手牌 + * @return true表示有特殊牌型潜力 + */ + private static boolean hasSpecialPatternPotential(List hand) { + // 快速判断:如果手牌中对子/刻子很多,可能是碰碰胡潜力 + Map counts = new HashMap<>(); + for (int c : hand) { + counts.put(c, counts.getOrDefault(c, 0) + 1); + } + + long pairOrMoreCount = counts.values().stream().filter(c -> c >= 2).count(); + + // 如果>=50%的牌种类都是对子或以上,认为有特殊牌型潜力 + if (counts.size() > 0 && pairOrMoreCount >= counts.size() * 0.5) { + return true; + } + + // 七对潜力:每种牌都是单张或双张,且没有三张以上 + long singleCount = counts.values().stream().filter(c -> c == 1).count(); + long doubleCount = counts.values().stream().filter(c -> c == 2).count(); + long triplePlusCount = counts.values().stream().filter(c -> c >= 3).count(); + + if (triplePlusCount == 0 && doubleCount >= 3 && singleCount <= 2) { + return true; // 很像在做七对 + } + + return false; + } + + /** + * 检测指定花色是否受门子限制规则约束(河池麻将特有) + * + * 河池规则: 吃了某花色的牌(如234筒)后,同花色同门子组的牌有限制 + * 门子分组: {1,4,7}, {2,5,8}, {3,6,9} + * + * @param chowCards 吃牌记录 + * @param suit 要检测的花色 + * @return true表示该花色受门子限制 + */ + private static boolean isSuitRestrictedByChow(List chowCards, int suit) { + if (chowCards == null || chowCards.isEmpty()) return false; + + // 检查是否有吃过这个花色 + for (int i = 0; i < chowCards.size(); i++) { + if (i + 2 < chowCards.size() && (i % 3) == 0) { + int c1 = chowCards.get(i); + int c2 = chowCards.get(i + 1); + int c3 = chowCards.get(i + 2); + + // 如果这一组吃的牌中有与目标花色相同的 + if (c1 / 100 == suit || c2 / 100 == suit || c3 / 100 == suit) { + return true; // 这个花色已被吃,受门子限制 + } + } + } + + return false; + } + + /** + * 生成有用的摸牌候选(只考虑对手牌有实际帮助的牌) + */ + private static Set generateUsefulDrawCandidates(List hand, List outCards) { + Set candidates = new HashSet<>(); + Set outSet = new HashSet<>(outCards); + + // 1. 同花色相邻/相隔的牌(可能形成顺子) + for (int card : hand) { + int type = card / 100; + int value = card % 100; + if (type >= 1 && type <= 3) { // 只有万筒条才考虑顺子 + for (int delta = -2; delta <= 2; delta++) { + if (delta == 0) continue; + int newValue = value + delta; + if (newValue >= 1 && newValue <= 9) { + int newCard = type * 100 + newValue; + if (!outSet.contains(newCard)) { + candidates.add(newCard); + } + } + } + } + } + + // 2. 手牌中已有的牌(可能形成刻子/将牌) + for (int card : hand) { + if (!outSet.contains(card)) { + candidates.add(card); // 再摸一张相同牌可以成刻/对 + } + } + + // 3. 风字牌(如果手中有单张风字牌) + for (int card : hand) { + int type = card / 100; + if ((type == 4 || type == 5) && !outSet.contains(card)) { + candidates.add(card); + } + } + + return candidates; + } + + /** + * 优先级排序采样牌(选择最有价值的牌进行模拟) + */ + private static List prioritizeSampleCards(List cards, List hand, int maxCount) { + // 根据与手牌的相关性排序 + cards.sort((a, b) -> { + int relevanceA = getCardRelevance(a, hand); + int relevanceB = getCardRelevance(b, hand); + return Integer.compare(relevanceB, relevanceA); // 降序 + }); + + return cards.subList(0, Math.min(maxCount, cards.size())); + } + + /** + * 计算一张牌与手牌的相关性(用于采样优先级) + */ + private static int getCardRelevance(int card, List hand) { + int type = card / 100; + int value = card % 100; + int relevance = 0; + + for (int handCard : hand) { + int handType = handCard / 100; + int handValue = handCard % 100; + + if (type != handType) continue; // 不同花色不相关 + + if (handValue == value) { + relevance += 5; // 相同牌(可能成刻) + } else if (Math.abs(handValue - value) <= 2) { + relevance += 3; // 相邻/隔张(可能成顺子) + } else if (Math.abs(handValue - value) <= 3) { + relevance += 1; // 边缘关联 + } + } + + // 中心牌(2-8)比边牌(1,9)更有价值 + if (value >= 2 && value <= 8) relevance += 1; + + return relevance; + } + + /** + * 评估手牌当前状态得分 + */ + private static double evaluateHandState(List hand, int hz, int committed, + int remainingMelds, List residuals) { + double score = 0; + + // 基础分:根据还差几手牌 + score -= remainingMelds * 8; + + // 孤章扣分 + score -= residuals.size() * 2; + + // 连接度加分(靠章越多越好) + int connectionDegree = calculateTotalConnectionDegree(hand); + score += Math.min(connectionDegree, 20); // 上限20分 + + // 对子保护加分 + Map counts = new HashMap<>(); + for (int c : hand) counts.put(c, counts.getOrDefault(c, 0) + 1); + int pairCount = (int) counts.values().stream().filter(c -> c >= 2).count(); + score += pairCount * 3; + + return score; + } + + /** + * 计算总连接度 + */ + private static int calculateTotalConnectionDegree(List hand) { + int totalDegree = 0; + for (int i = 0; i < hand.size(); i++) { + for (int j = i + 1; j < hand.size(); j++) { + int ci = hand.get(i), cj = hand.get(j); + if (ci / 100 == cj / 100 && Math.abs(ci - cj) <= 2) { + totalDegree++; + } + } + } + return totalDegree; + } + + /** + * 评估摸到某张牌的改进程度 + */ + private static double evaluateImprovement(int drawnCard, List beforeHand, List outCards) { + double improvement = 0; + int type = drawnCard / 100; + int value = drawnCard % 100; + + // 检查摸牌前是否有这个花色的孤章被改善了 + boolean hadIsolatedInSuit = false; + int connectionsBefore = 0; + for (int card : beforeHand) { + if (card / 100 == type && Math.abs(card - drawnCard) <= 2) { + connectionsBefore++; + hadIsolatedInSuit = true; + } + } + + improvement += connectionsBefore * 0.5; // 连接度提升 + + // 中心牌改进价值更高 + if (value >= 3 && value <= 7) improvement += 0.3; + + // 形成新对子/刻子的价值 + int countBefore = 0; + for (int c : beforeHand) if (c == drawnCard) countBefore++; + if (countBefore == 1) improvement += 1.0; // 形成对子 + if (countBefore == 2) improvement += 1.5; // 形成刻子 + + return improvement; + } + + // ==================== 优化2:防守意识模块 ==================== + + /** + * 危险牌识别器 + * 分析其他玩家的行为模式,判断哪些牌是危险牌(容易放炮) + * + * @param card 待检测的牌 + * @param allOutCards 所有玩家已打出的牌 + * @param pengCards 所有玩家碰过的牌 + * @param chowCards 所有玩家吃过的牌 + * @param gangCards 所有玩家杠过的牌 + * @param myHand 我的手牌 + * @return 危险等级 (0=安全, 1=轻度危险, 2=危险, 3=极度危险) + */ + public static int assessDangerLevel(int card, List allOutCards, + List pengCards, List chowCards, + List gangCards, List myHand) { + + int dangerLevel = 0; + int suit = card / 100; + int value = card % 100; + + // ===== 规则1:检查是否有人做过同花色的明杠/暗杠 ===== + for (int gangCard : gangCards) { + if (gangCard / 100 == suit) { + dangerLevel += 1; + // System.out.println(" [防守] 危险信号:有人杠过" + suit + "花色的牌"); //已精简 + break; + } + } + + // ===== 规则2:检查碰牌模式(某人连碰同一花色多次)===== + Map suitPengCount = new HashMap<>(); + for (int p : pengCards) { + int s = p / 100; + suitPengCount.put(s, suitPengCount.getOrDefault(s, 0) + 1); + } + if (suitPengCount.getOrDefault(suit, 0) >= 2) { + dangerLevel += 1; + // System.out.println(" [防守] 危险信号:" + suit + "花色已被碰" + suitPengCount.get(suit) + "次"); //已精简 + } + + // ===== 规则3:检查吃牌形成的嵌张/边张 ===== + for (int i = 0; i < chowCards.size(); i += 3) { + if (i + 2 < chowCards.size()) { + int c1 = chowCards.get(i), c2 = chowCards.get(i + 1), c3 = chowCards.get(i + 2); + if (c1 / 100 == suit || c2 / 100 == suit || c3 / 100 == suit) { + // 有人吃过这个花色,可能是在做清一色或大牌 + if (suitPengCount.getOrDefault(suit, 0) > 0 || !gangCards.isEmpty()) { + dangerLevel += 1; + // System.out.println(" [防守] 危险信号:有人吃过+碰过/杠过" + suit + "花色"); //已精简 + } + } + } + } + + // ===== 规则4:检查剩余牌量(如果某张牌从未出现过且是中心牌)===== + if (!allOutCards.contains(card) && value >= 3 && value <= 7) { + // 这张牌没人打过,可能是别人要的 + dangerLevel += 1; + } + + // ===== 规则5:检查我手中该花色的安全牌 vs 危险牌比例 ===== + int safeCount = 0, dangerousCount = 0; + for (int c : myHand) { + if (c / 100 == suit) { + int v = c % 100; + if (v == 1 || v == 9) safeCount++; // 边张相对安全 + else dangerousCount++; // 中间张较危险 + } + } + if (dangerousCount > safeCount * 2 && value >= 3 && value <= 7) { + dangerLevel += 1; // 手里中间牌太多,说明在做这个花色 + } + + // 限制最大危险等级为3 + return Math.min(dangerLevel, 3); + } + + /** + * 防守性出牌调整:如果最优出牌太危险,考虑次优但安全的替代方案 + * + * @param originalChoice 原始最优选择 + * @param alternatives 备选方案 + * @param allOutCards 所有已出牌 + * @param pengCards 碰牌记录 + * @param chowCards 吃牌记录 + * @param gangCards 杠牌记录 + * @param myHand 手牌 + * @return 调整后的出牌(可能返回原选择如果不需调整) + */ + public static Integer adjustForDefense(Integer originalChoice, List alternatives, + List allOutCards, List pengCards, + List chowCards, List gangCards, + List myHand) { + + if (originalChoice == null || alternatives == null || alternatives.size() <= 1) { + return originalChoice; + } + + // 评估原选择的危险等级 + int dangerLevel = assessDangerLevel(originalChoice, allOutCards, pengCards, chowCards, gangCards, myHand); + + if (dangerLevel >= 2) { + // 危险等级较高,寻找更安全的替代品 + // System.out.println("[防守] 原选择 " + originalChoice + " 危险等级=" + dangerLevel + ",寻找安全替代..."); //已精简 + + // 在备选方案中找危险等级最低的 + Integer safestAlternative = null; + int lowestDanger = dangerLevel; + + for (Integer alt : alternatives) { + if (alt.equals(originalChoice)) continue; + int altDanger = assessDangerLevel(alt, allOutCards, pengCards, chowCards, gangCards, myHand); + // System.out.println(" [防守] 替代品 " + alt + " 危险等级=" + altDanger); //已精简 + if (altDanger < lowestDanger) { + lowestDanger = altDanger; + safestAlternative = alt; + } + } + + if (safestAlternative != null && lowestDanger < dangerLevel) { + // System.out.println("[防守] 安全调整:" + originalChoice + "(危险" + dangerLevel + ") → " + + // safestAlternative + "(危险" + lowestDanger + ")"); //已精简 + return safestAlternative; + } else { + // System.out.println("[防守] 无更安全替代,仍使用原选择(风险可控)"); //已精简 + } + } + + return originalChoice; + } + + // ==================== 优化3:听牌质量评估(EV计算) ==================== + + /** + * 计算听牌质量(期望值 EV) + * + * 听牌质量不仅仅看听几张,还要考虑: + * 1. 每张听牌的剩余张数(已出现的不能胡) + * 2. 听牌的位置(中间张比边张更容易摸到) + * 3. 听牌的花色分布(分散在不同花色更好) + * + * @param hand 当前手牌(不含红中) + * @param hz 红中数量 + * @param committed 已吃碰杠牌数 + * @param outCards 已打出的牌 + * @return 听牌质量分数(越高越好,通常在0-30之间) + */ + public static double calculateTingQuality(List hand, int hz, int committed, List outCards) { + List tingCards = checktingpaiWithWildcard(hand, hz, committed); + if (tingCards.isEmpty()) { + return 0; + } + + double totalQuality = 0; + Set outSet = new HashSet<>(outCards); + + // 获取详细的听牌信息(听什么牌能胡) + Map> tingDetails = ChangShaSuanFaTest.quyizhangTingPai(hand); + + for (Integer discardChoice : tingCards) { + if (!tingDetails.containsKey(discardChoice)) continue; + + List winCards = tingDetails.get(discardChoice); // 打这张牌后能胡哪些牌 + + for (Integer winCard : winCards) { + double cardValue = evaluateSingleTingCard(winCard, outSet); + totalQuality += cardValue; + } + } + + return totalQuality; + } + + /** + * 评估单张听牌的价值 + */ + private static double evaluateSingleTingCard(int card, Set outCards) { + int suit = card / 100; + int value = card % 100; + double baseValue = 1.0; + + // 因素1:剩余张数(假设4张/种,减去已出现的) + int appearedCount = 0; + for (Integer out : outCards) { + if (out.equals(card)) appearedCount++; + } + int remaining = 4 - appearedCount; // 最多4张 + + if (remaining <= 0) return 0; // 已经没了,听了个寂寞 + + baseValue *= (remaining / 4.0); // 剩余越少价值越低 + + // 因素2:位置价值(中间牌更容易组成顺子/被摸到) + if (value >= 3 && value <= 7) { + baseValue *= 1.3; // 中间牌加权 + } else if (value == 2 || value == 8) { + baseValue *= 1.0; // 次边张正常 + } else { // value == 1 或 9 + baseValue *= 0.8; // 边张打折 + } + + // 因素3:花色价值(万筒条等价,风字牌稍低因为组合少) + if (suit >= 4) { + baseValue *= 0.9; // 风字牌稍低 + } + + return baseValue; + } + + // ==================== 优化4:动态策略调整 ==================== + + /** 游戏阶段枚举 */ + public enum GamePhase { + EARLY(0, "前期", 1.2, 0.8), // 前期:进攻为主(权重:效率*1.2, 安全*0.8) + MIDDLE(1, "中期", 1.0, 1.0), // 中期:平衡 + LATE(2, "后期", 0.7, 1.3); // 后期:防守为主(权重:效率*0.7, 安全*1.3) + + public final int code; + public final String name; + public final double offenseWeight; // 进攻权重 + public final double defenseWeight; // 防守权重 + + GamePhase(int code, String name, double offense, double defense) { + this.code = code; + this.name = name; + this.offenseWeight = offense; + this.defenseWeight = defense; + } + } + + /** + * 判断游戏当前处于哪个阶段 + * + * 判断依据: + * - 前期:墙牌剩余 > 50%(约摸牌< 25轮) + * - 中期:墙牌剩余 25%-50% + * - 后期:墙牌剩余 < 25% 或 有人听牌/做大牌 + * + * @param wallRemaining 剩余墙牌数(估算) + * @param isAnyListening 是否有人已经听牌 + * @param myHandSize 我的手牌数 + * @return 游戏阶段 + */ + public static GamePhase determineGamePhase(int wallRemaining, boolean isAnyListening, int myHandSize) { + // 总牌数估算(不含花牌):136张 - 已发牌 + int estimatedTotal = 136; // 标准麻将136张 + double remainingRatio = (double) wallRemaining / estimatedTotal; + + if (isAnyListening || remainingRatio < 0.25) { + return GamePhase.LATE; + } else if (remainingRatio < 0.5) { + return GamePhase.MIDDLE; + } else { + return GamePhase.EARLY; + } + } + + /** + * 根据游戏阶段动态调整出牌策略 + * + * @param candidates 候选牌 + * @param phase 当前游戏阶段 + * @param offensiveScores 每张牌的进攻得分(来自多轮模拟) + * @param defensiveScores 每张牌的防守得分(来自危险评估) + * @return 调整后的最优出牌 + */ + public static Integer dynamicStrategyAdjustment(List candidates, GamePhase phase, + Map offensiveScores, + Map defensiveScores) { + + if (candidates == null || candidates.isEmpty()) return null; + if (candidates.size() == 1) return candidates.get(0); + + // System.out.println("[策略] 当前游戏阶段: " + phase.name + + // " (进攻权重:" + phase.offenseWeight + ", 防守权重:" + phase.defenseWeight + ")"); //已精简 + + Integer bestCard = null; + double bestCombinedScore = Double.NEGATIVE_INFINITY; + + for (Integer card : candidates) { + double offScore = offensiveScores.getOrDefault(card, 0.0); + double defScore = defensiveScores.getOrDefault(card, 0.0); + + // 加权综合分 = 进攻分 * 进攻权重 + 防守分 * 防守权重 + // 注意:防守分需要反转(危险越低越好,所以用负数或取反) + double combinedScore = offScore * phase.offenseWeight - defScore * phase.defenseWeight; + + // System.out.println(" [策略] " + card + ": 进攻=" + String.format("%.1f", offScore) + + // ", 防御=" + String.format("%.1f", defScore) + + // ", 综合=" + String.format("%.1f", combinedScore)); //已精简 + + if (combinedScore > bestCombinedScore) { + bestCombinedScore = combinedScore; + bestCard = card; + } + } + + return bestCard != null ? bestCard : candidates.get(0); + } + + // ==================== 优化5:碰杠破坏性评估 ==================== + + /** + * 评估碰/杠操作的破坏性成本 + * + * 碰杠虽然能推进进度,但也可能: + * 1. 拆散有效的连牌结构 + * 2. 减少听牌面广度 + * 3. 暴露自己的意图给对手 + * + * @param operationType 操作类型(1=吃, 2=碰, 3=明杠, 4=补杠, 5=暗杠) + * @param card 操作涉及的牌 + * @param handBefore 操作前的手牌 + * @param handAfter 操作后的手牌(已移除碰杠的牌) + * @param beforeListening 是否操作前已听牌 + * @param afterListening 操作后是否能听牌 + * @return 破坏性评分(负数=有害,正数=有益,0=中性) + */ + public static double assessOperationDamage(int operationType, int card, + List handBefore, List handAfter, + boolean beforeListening, boolean afterListening) { + + double damage = 0; + + // 基础收益:如果操作后能听牌,基础收益高 + if (afterListening && !beforeListening) { + damage -= 30; // 能下听是大好事(用负数表示正面效果) + } else if (afterListening && beforeListening) { + damage -= 10; // 保持听牌也有小好处 + } + + // 破坏性评估 + if (operationType == 2 || operationType == 3) { // 碰或杠 + // 检查是否拆散了有效结构 + double structureLoss = evaluateStructureDamage(card, handBefore); + damage += structureLoss; + + // 检查是否减少了听牌面 + if (!beforeListening) { + List tingBefore = checktingpai(handBefore); + List tingAfter = checktingpai(handAfter); + // 这里只是示意,实际上应该比较听牌的质量 + if (tingAfter.size() < tingBefore.size()) { + damage += 5; // 听牌选项减少 + } + } + } + + // 特殊情况:后期碰杠的收益更高(减少手牌数,降低放炮风险) + if (operationType == 2) { // 碰 + damage -= 3; // 碰牌减少2张手牌,降低风险 + } + if (operationType >= 3) { // 杠 + damage -= 5; // 杠牌减少更多手牌 + } + + return damage; + } + + /** + * 评估拆掉某张牌对整体结构的破坏程度 + */ + private static double evaluateStructureDamage(int removedCard, List hand) { + double loss = 0; + int suit = removedCard / 100; + int value = removedCard % 100; + + // 检查这张牌在手牌中的连接情况 + boolean hasLeftNeighbor = false, hasRightNeighbor = false; + boolean hasGapLeft = false, hasGapRight = false; + + for (int c : hand) { + if (c == removedCard) continue; + if (c / 100 != suit) continue; + + int diff = c - removedCard; + if (diff == -1) hasLeftNeighbor = true; + if (diff == 1) hasRightNeighbor = true; + if (diff == -2) hasGapLeft = true; + if (diff == 2) hasGapRight = true; + } + + // 如果这张牌是某个连牌的核心部分,拆掉损失大 + if (hasLeftNeighbor && hasRightNeighbor) { + loss += 15; // 三连牌中间张,拆掉很伤 + System.out.println(" [破坏评估] " + removedCard + " 是三连牌核心,损失+15"); + } else if (hasLeftNeighbor || hasRightNeighbor) { + loss += 8; // 二连牌的一部分 + System.out.println(" [破坏评估] " + removedCard + " 是二连牌的一部分,损失+8"); + } else if (hasGapLeft || hasGapRight) { + loss += 3; // 嵌张的一部分 + System.out.println(" [破坏评估] " + removedCard + " 是嵌张的一部分,损失+3"); + } + + // 检查是否拆掉了对子 + int count = 0; + for (int c : hand) if (c == removedCard) count++; + if (count >= 2) { + loss += 12; // 拆对子代价大(尤其可能影响将牌) + System.out.println(" [破坏评估] " + removedCard + " 是对子的一部分(count=" + count + "),损失+12"); + } + + return loss; + } + + // ==================== 集成方法:统一调用入口 ==================== + + /** + * 🚀 智能出牌决策终极接口 + * + * 集成所有优化模块,给出最终出牌建议: + * 1. 多轮模拟前瞻(优化1) + * 2. 防守意识评估(优化2) + * 3. 听牌质量分析(优化3) + * 4. 动态策略调整(优化4) + * + * @param cardInHand 手牌(不含红中) + * @param hongZhongCount 红中数量 + * @param committedCardCount 已吃碰杠牌数 + * @param allOutCards 所有已出牌(用于防守分析) + * @param pengCards 所有碰牌 + * @param chowCards 所有吃牌 + * @param gangCards 所有杠牌 + * @param gamePhase 当前游戏阶段(可选,null则自动判断) + * @return 最优出牌建议 + */ + public static Integer intelligentDiscardDecision( + List cardInHand, + int hongZhongCount, + int committedCardCount, + List allOutCards, + List pengCards, + List chowCards, + List gangCards, + GamePhase gamePhase) { + + // System.out.println("\n========== 🤖 启动智能出牌决策引擎 =========="); //已精简 + // System.out.println("手牌数:" + cardInHand.size() + ", 红中:" + hongZhongCount + + // ", 已出牌:" + (allOutCards != null ? allOutCards.size() : 0)); //已精简 + + // Step 1: 获取基础候选牌(使用原有算法) + List basicCandidates = analyzeBestDiscardWithWildcard( + cardInHand, hongZhongCount, committedCardCount); + + if (basicCandidates.isEmpty()) { + // System.out.println(" 无候选牌,使用默认逻辑"); //已精简 + return cardInHand.isEmpty() ? null : cardInHand.get(0); + } + + // System.out.println("Step1 基础候选: " + basicCandidates); //已精简 + + // Step 2: 多轮模拟前瞻(优化1) + // 【性能修复】只调用一次 selectByMultiRoundSimulation,内部完成所有模拟和评分 + Integer choiceAfterSim = selectByMultiRoundSimulation( + basicCandidates, cardInHand, hongZhongCount, committedCardCount, + allOutCards != null ? allOutCards : new ArrayList<>()); + + // 【性能修复】构建简化的进攻分数映射(避免重复模拟) + // 最优候选给满分100,其余按连接度递减(不需要精确值,只需要相对排序) + Map offensiveScores = new LinkedHashMap<>(); + int rank = 0; + for (Integer card : basicCandidates) { + if (card.equals(choiceAfterSim)) { + offensiveScores.put(card, 100.0); + } else { + offensiveScores.put(card, 80.0 - rank * 3); // 简化递减分 + } + rank++; + } + + // System.out.println("Step2 多轮模拟选择: " + choiceAfterSim); //已精简 + + // Step 3: 防守意识检查(优化2) + Integer choiceAfterDefense = adjustForDefense( + choiceAfterSim, basicCandidates, + allOutCards != null ? allOutCards : new ArrayList<>(), + pengCards != null ? pengCards : new ArrayList<>(), + chowCards != null ? chowCards : new ArrayList<>(), + gangCards != null ? gangCards : new ArrayList<>(), + cardInHand); + + // System.out.println("Step3 防守调整后: " + choiceAfterDefense); //已精简 + + // Step 4: 动态策略调整(优化4) + if (gamePhase == null) { + // 自动判断游戏阶段 + int wallEstimate = 136 - (allOutCards != null ? allOutCards.size() : 0); + gamePhase = determineGamePhase(wallEstimate, false, cardInHand.size()); + } + + // 构建防守分数映射 + Map defensiveScores = new LinkedHashMap<>(); + for (Integer card : basicCandidates) { + int danger = assessDangerLevel(card, + allOutCards != null ? allOutCards : new ArrayList<>(), + pengCards != null ? pengCards : new ArrayList<>(), + chowCards != null ? chowCards : new ArrayList<>(), + gangCards != null ? gangCards : new ArrayList<>(), + cardInHand); + defensiveScores.put(card, (double) danger); + } + + Integer finalChoice = dynamicStrategyAdjustment( + basicCandidates, gamePhase, offensiveScores, defensiveScores); + + System.out.println("【智能决策】→ " + finalChoice); //保留唯一关键输出 + // System.out.println("========== 智能决策完成 ==========\n"); //已精简 + + return finalChoice; + } + // 从候选牌中选出最优的一张牌(优先边张1或9) - public static Integer selectBestSingleCard(List cards) { + // 【增强】增加对子保护:同等优先级下优先打非对子牌 + public static Integer selectBestSingleCard(List cards, List handCards) { if (cards == null || cards.isEmpty()) { return null; } - //使用Stream API简化查找逻辑 - return cards.stream() - .filter(card -> { - int value = card % 100; - return value == 1 || value == 9; - }) - .findFirst() - .orElseGet(() -> cards.stream() - .filter(card -> { - int value = card % 100; - return value == 2 || value == 8; - }) - .findFirst() - .orElse(cards.get(0))); + // ===== 对子保护:分类对子/非对子 ===== + List nonPairCards = new ArrayList<>(); + List pairCards = new ArrayList<>(); + for (Integer card : cards) { + int count = 0; + if (handCards != null) { + for (int c : handCards) { + if (c == card) count++; + } + } + if (count >= 2) { + pairCards.add(card); + } else { + nonPairCards.add(card); + } + } + + // 优先找边张(1或9)—— 非对子优先 + Integer result = findByValue(nonPairCards, 1, 9); + if (result != null) return result; + result = findByValue(pairCards, 1, 9); + if (result != null) return result; + + // 次边张(2或8)—— 非对子优先 + result = findByValue(nonPairCards, 2, 8); + if (result != null) return result; + result = findByValue(pairCards, 2, 8); + if (result != null) return result; + + // 默认优先非对子 + if (!nonPairCards.isEmpty()) return nonPairCards.get(0); + return cards.get(0); + } + + private static Integer findByValue(List cards, int v1, int v2) { + if (cards == null) return null; + for (Integer card : cards) { + int value = card % 100; + if (value == v1 || value == v2) return card; + } + return null; } // 主方法 diff --git a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/TinHuChi.java b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/TinHuChi.java index 3267839..281b1c7 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/TinHuChi.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/TinHuChi.java @@ -7,130 +7,80 @@ import java.util.*; public class TinHuChi { /** - * 测试方法 + * 测试方法 - 验证核心Bug修复 */ public static void main(String[] args) { -// System.out.println("=== 测试开始 ===\n"); -// -// // 测试1:你的例子 - List hand1 = new ArrayList<>(); + System.out.println("=== 核心Bug修复验证测试 ===\n"); - hand1.add(209); - hand1.add(209); - hand1.add(206); - hand1.add(206); - hand1.add(104); - hand1.add(104); - hand1.add(105); - hand1.add(105); - hand1.add(101); - hand1.add(202); - hand1.add(201); - hand1.add(109); - hand1.add(203); - - - - ChangShaSuanFaTest changShaSuanFaTest = new ChangShaSuanFaTest(); - String s = changShaSuanFaTest.outCardSuanFa(hand1, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), 1); - -// int addcard =203; - -// Map map = new HashMap<>(); -// //碰之后的map -// Map map2 = new HashMap<>(); -// -// //先判断碰之前还需要几手牌,以及孤章 -//// int jiangnum = checkduijiang(changShaCardInhand); -// List tmpChangSch = new ArrayList<>(); -// tmpChangSch.addAll(hand1); -//// tmpChangSch.add(addcard); -// int i = ChangshaWinSplitCard.checkNormalHu(tmpChangSch, map); -// System.out.println("checkNormalHu 孤章数量 " + ((List) map.get("cardResiue")).size()); -// System.out.println("checkNormalHu 手数 " + map.get("remainingMelds")); -// -// -// //碰之后 -// List pengtemphand = new ArrayList<>(); -// pengtemphand.addAll(tmpChangSch); -// Util.removeCard(pengtemphand, addcard, 3); -// -// ChangshaWinSplitCard.checkNormalHu(pengtemphand, map2); -// //判断两个map是否找到更优 -// System.out.println("碰之前 map1: 手数" + Integer.parseInt(map.get("remainingMelds").toString())); -// System.out.println("碰之后 map2 手数:" + Integer.parseInt(map2.get("remainingMelds").toString())); -// //碰之后的手数小于碰之前的手数,可以碰 -// if (Integer.parseInt(map2.get("remainingMelds").toString()) < Integer.parseInt(map.get("remainingMelds").toString())) { -// System.out.println("===============碰之后的手数小于碰之前的手数,可以碰 决定碰牌================== ++++ "); -// } else if (Integer.parseInt(map2.get("remainingMelds").toString()) == Integer.parseInt(map.get("remainingMelds").toString())) { //碰完后和碰之前手数相等,需要判断孤章数量 -// //碰之后的数量 -// int size2 = ((List) map2.get("cardResiue")).size(); -// System.out.println("碰之后的孤章数量 " + size2); -// -// int size1 = ((List) map.get("cardResiue")).size(); -// System.out.println("碰之前的孤章数量 " + size1); -// if (size2 < size1) { -// System.out.println("===============碰之后的孤章数量 小于 碰之前的孤章数量可以碰 ================== ++++ "); -// } -// //碰之后的手数大于碰之前的手数,或者碰之后的孤章数量大于碰之前的孤章数量不能碰 -// } else if (Integer.parseInt(map2.get("remainingMelds").toString()) > Integer.parseInt(map.get("remainingMelds").toString()) || ((List) map2.get("cardResiue")).size() > ((List) map.get("cardResiue")).size()) { -// System.out.println("碰之后的手数大于碰之前的手数,或者碰之后的孤章数量大于碰之前的孤章数量不能碰"); -// } - } - - - public static List checktingpai(List cardhand) { - List tpcards = new ArrayList<>(); - List tmphc = cardhand; - for (int i = 0; i < cardhand.size(); i++) { - - int tmpcard = tmphc.get(0); - tmphc.remove(0); - for (int j = 101; j <= 109; j++) { - WinCard win = new WinCard(tmphc, j); - if (win.tryWin()) { - if (!tpcards.contains(tmpcard)) { - tpcards.add(tmpcard); - } - } - } - for (int j = 201; j <= 209; j++) { - WinCard win = new WinCard(tmphc, j); - if (win.tryWin()) { - if (!tpcards.contains(tmpcard)) { - tpcards.add(tmpcard); - } - } - } - - for (int j = 301; j <= 309; j++) { - WinCard win = new WinCard(tmphc, j); - if (win.tryWin()) { - if (!tpcards.contains(tmpcard)) { - tpcards.add(tmpcard); - } - } - } - for (int j = 401; j <= 404; j++) { - WinCard win = new WinCard(tmphc, j); - if (win.tryWin()) { - if (!tpcards.contains(tmpcard)) { - tpcards.add(tmpcard); - } - } - } - - for (int j = 502; j <= 503; j++) { - WinCard win = new WinCard(tmphc, j); - if (win.tryWin()) { - if (!tpcards.contains(tmpcard)) { - tpcards.add(tmpcard); - } - } - } - tmphc.add(tmpcard); + // ===== 测试场景:杠后11张手牌,应该检测到打八万(108)能听牌 ===== + // 用户原始问题:机器人杠六万后抓到七万,手牌为: + // [107, 108×2, 204, 205, 206, 302×2, 305, 306, 307] + // 预期结果:打108(拆八万对子)后,能听106(六万)或109(九万) + + System.out.println("【测试场景1】杠后11张 - 多对子听牌检测"); + List testHand1 = new ArrayList<>(); + testHand1.add(107); // 七万 + testHand1.add(108); // 八万 + testHand1.add(108); // 八万(对子) + testHand1.add(204); // 四饼 + testHand1.add(205); // 五饼 + testHand1.add(206); // 六饼 + testHand1.add(302); // 二条 + testHand1.add(302); // 二条(对子) + testHand1.add(305); // 五条 + testHand1.add(306); // 六条 + testHand1.add(307); // 七条 + + System.out.println("手牌:" + testHand1); + System.out.println("牌数:" + testHand1.size() + "张"); + + // 测试1:checktingpaiWithWildcard 能否识别出打108后能听牌 + List tingResult = ChangshaWinSplitCard.checktingpaiWithWildcard(testHand1, 0, 0); + System.out.println("\n checktingpaiWithWildcard结果(打出这些牌可听): " + tingResult); + + if (tingResult.contains(108)) { + System.out.println(" 【成功】正确识别出打108(八万)可以听牌!"); + } else if (!tingResult.isEmpty()) { + System.out.println(" 部分成功:检测到可听牌" + tingResult + ",但未包含108"); + } else { + System.out.println(" 失败:未检测到任何听牌选项(原bug状态)"); } - return tpcards; + + // 测试2:analyzeBestDiscardWithWildcard 能否生成包含108的候选列表 + List discardCandidates = ChangshaWinSplitCard.analyzeBestDiscardWithWildcard(testHand1, 0, 0); + System.out.println("\n analyzeBestDiscardWithWildcard候选列表: " + discardCandidates); + + if (discardCandidates.contains(108)) { + System.out.println(" 【成功】候选列表包含108(允许拆对子换取听牌)!"); + } else { + System.out.println(" 候选列表不包含108: " + discardCandidates); + } + + // 测试3:完整出牌决策流程 + System.out.println("\n===== 完整出牌决策测试 ====="); + ChangShaSuanFaTest changShaSuanFaTest = new ChangShaSuanFaTest(); + String finalDecision = changShaSuanFaTest.outCardSuanFa( + testHand1, + new ArrayList<>(), // chowGroup + new ArrayList<>(), // pengCard + new ArrayList<>(), // gangdepai + new ArrayList<>(), // 已出牌列表 + 1 // 游戏阶段 + ); + + System.out.println("最终决策 → 打出: " + finalDecision); + + if (finalDecision.equals("108")) { + System.out.println(" 决策正确!选择打八万(108)进入听牌状态!"); + } else if (finalDecision.equals("206")) { + System.out.println(" 决策错误!打了六饼(206)而不是八万(108)"); + } else if (finalDecision.equals("302")) { + System.out.println(" 决策保守:打二条(302),未追求最优听牌"); + } else { + System.out.println(" 决策:打出" + finalDecision); + } + + System.out.println("\n=== 测试完成 ===\n"); } /** @@ -150,4 +100,4 @@ public class TinHuChi { } -} \ No newline at end of file +} diff --git a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/WinCard.java b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/WinCard.java index e1687ea..7df6ec9 100644 --- a/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/WinCard.java +++ b/robots/majiang/robot_mj_hechi/src/main/java/taurus/util/WinCard.java @@ -151,44 +151,131 @@ public class WinCard { return true; } - public boolean tryWin() { - if (this.cardList.size() == 0 && this.pair_count == 1) { + /** + * 2个万能牌+1张相同牌组成刻子 + * 例如:1张五万 + 2个红中 = 五万刻子 + */ + private boolean tryKezi2Zhong(int card) { + if (this.zhong_count >= 2 && Util.checkCardAndRomve(card, this.cardList, 1)) { + List cardGroup = Arrays.asList(card, this.zhongid, this.zhongid); + this.zhong_count -= 2; + this.push(cardGroup); + return true; + } + return false; + } + + /** + * 2个万能牌+1张牌组成顺子 + * 尝试3种位置:牌在首位/中间/末位 + * 例如:1张五万 + 2个红中 = 四万五万六万 或 五万六万七万 或 三万四万五万 + */ + private boolean tryShunzi2Zhong(int card) { + int suit = card / 100; + if (suit < 1 || suit > 3) { + return false; + } + + int cardValue = card % 100; + + if (this.zhong_count < 2) { + return false; + } + + // 配置1:牌在首位(V, W, W),V需<=7 + if (cardValue <= 7 && Util.checkCardAndRomve(card, this.cardList, 1)) { + this.zhong_count -= 2; + List cardGroup = Arrays.asList(card, this.zhongid, this.zhongid); + this.push(cardGroup); return true; } - if (this.cardList.size() == 0) { + // 配置2:牌在末位(W, W, V),V需>=3 + if (cardValue >= 3 && this.zhong_count >= 2 && Util.checkCardAndRomve(card, this.cardList, 1)) { + this.zhong_count -= 2; + List cardGroup = Arrays.asList(this.zhongid, this.zhongid, card); + this.push(cardGroup); + return true; + } + + // 配置3:牌在中间(W, V, W),V需>=2且<=8 + if (cardValue >= 2 && cardValue <= 8 && this.zhong_count >= 2 && Util.checkCardAndRomve(card, this.cardList, 1)) { + this.zhong_count -= 2; + List cardGroup = Arrays.asList(this.zhongid, card, this.zhongid); + this.push(cardGroup); + return true; + } + + return false; + } + + /** + * 红中自身组成刻子(3个红中=红中刻子) + * 不需要普通牌参与,仅消耗3个红中 + */ + private boolean tryZhongKezi() { + if (this.zhong_count >= 3) { + this.zhong_count -= 3; + List cardGroup = Arrays.asList(this.zhongid, this.zhongid, this.zhongid); + this.push(cardGroup); + return true; + } + return false; + } + + /** + * 红中自身组成对子/将牌(2个红中=红中对子) + */ + private boolean tryZhongPair() { + if (this.pair_count > 0) { return false; } + if (this.zhong_count >= 2) { + this.zhong_count -= 2; + List cardGroup = Arrays.asList(this.zhongid, this.zhongid); + this.push(cardGroup); + this.pair_count = 1; + return true; + } + return false; + } + + public boolean tryWin() { + // 终止条件:所有牌(普通牌+红中)都分配完毕,且有将牌 → 胡牌 + if (this.cardList.size() == 0 && this.zhong_count == 0 && this.pair_count == 1) { + return true; + } + + // cardList为空但还有红中剩余 → 红中自身组牌(兜底:只有普通牌全拆完了才让红中自己组) + if (this.cardList.size() == 0) { + // 3个红中=红中刻子 + if (tryZhongKezi()) { + if (tryWin()) { + return true; + } + this.rollBack(); + } + // 2个红中=对子(将牌) + if (tryZhongPair()) { + if (tryWin()) { + return true; + } + this.pair_count = 0; + this.rollBack(); + } + // 红中剩余1个无法组任何牌 → 不可能胡牌 + return false; + } + int activeCard = this.cardList.get(0); - if (tryPair(activeCard)) { - if (tryWin()) { - return true; - } - this.pair_count = 0; - this.rollBack(); - } - - if (tryKezi(activeCard)) { - if (tryWin()) { - return true; - } - this.rollBack(); - - } - if (tryShunzi(activeCard)) { - if (tryWin()) { - return true; - } - this.rollBack(); - } - + // 红中作为万能牌替代其他牌(优先级高于红中自身组牌) + // 1个万能牌组合(1红中替代1张缺失牌) if (tryKezi1Zhong(activeCard)) { if (tryWin()) { return true; } this.rollBack(); - } if (tryShunzi1Zhong(activeCard)) { if (tryWin()) { @@ -204,6 +291,41 @@ public class WinCard { this.rollBack(); } + // 2个万能牌组合(2红中替代2张缺失牌) + if (tryKezi2Zhong(activeCard)) { + if (tryWin()) { + return true; + } + this.rollBack(); + } + if (tryShunzi2Zhong(activeCard)) { + if (tryWin()) { + return true; + } + this.rollBack(); + } + + // 普通牌组合(不含红中,红中不够替代时走这条路) + if (tryPair(activeCard)) { + if (tryWin()) { + return true; + } + this.pair_count = 0; + this.rollBack(); + } + if (tryKezi(activeCard)) { + if (tryWin()) { + return true; + } + this.rollBack(); + } + if (tryShunzi(activeCard)) { + if (tryWin()) { + return true; + } + this.rollBack(); + } + return false; } @@ -221,6 +343,27 @@ public class WinCard { Collections.sort(this.cardList); } + /** + * 河池麻将专用构造器:支持指定万能牌(红中501)数量 + * cardInhand中不应包含红中(已过滤),红中数量通过wildCardCount传入 + * @param cardInhand 不含红中的手牌 + * @param addCard 待添加的牌(0表示不添加) + * @param wildCardCount 万能牌(红中)数量 + * @param wildCardId 万能牌ID(河池麻将红中=501) + */ + public WinCard(List cardInhand, int addCard, int wildCardCount, int wildCardId) { + this.stack = new Stack>(); + this.cardList = new ArrayList(); + this.cardList.addAll(cardInhand); + this.zhongid = wildCardId; + this.zhong_count = wildCardCount; + if (addCard != 0) { + this.cardList.add(addCard); + } + + Collections.sort(this.cardList); + } + public static void main(String[] args) { long time = System.currentTimeMillis();