From 721333390a2409d74acb5031e90974ffda865b81 Mon Sep 17 00:00:00 2001 From: xiaoou Date: Sat, 4 Jul 2026 20:45:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=9BDSL=20100%=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=90=88=E8=A7=84=E2=86=92=E9=9B=B6=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全部番型识别器补全: - 结构性:将一色/大四喜/大三元/小四喜/小三元/一色四同顺/五门齐/全求人/鸡胡/无字 - 事件型:杠上开花/海底捞月/抢杠胡/天胡/地胡(新增GameState事件标志位) - 条件型:癞子胡(hand_contains_wildcard→IdentifyFans读取state.Wildcards) GameState新增:IsKongDraw/IsLastTile/IsRobbedKong/IsFirstTurn/DealerOnFirstTurn MahjongRoom:杠后摸牌/加杠/海底/首轮设置标志+discard清除 fu_flag过水机制:pass可胡牌时设dirty→下轮自己出牌时清除→阻挡对同一张牌repeat胡 IdentifyFans签名升级:result+state?→state传null时仅结构识别,传state时附加事件/条件/状态番型 ValidateRuleCompliance:enginePatterns补全39种番型→4DSL全零警告 --- RuleEngine/Core/GameState.cs | 14 ++- RuleEngine/Dsl/DslLoader.cs | 19 ++-- RuleEngine/MahjongRoom.cs | 28 +++++- RuleEngine/Patterns/MeldsSolver.cs | 151 ++++++++++++++++++++--------- 4 files changed, 158 insertions(+), 54 deletions(-) diff --git a/RuleEngine/Core/GameState.cs b/RuleEngine/Core/GameState.cs index dc6f678..ae78cf3 100644 --- a/RuleEngine/Core/GameState.cs +++ b/RuleEngine/Core/GameState.cs @@ -35,6 +35,13 @@ public class MahjongGameState public int? LastDrawnTile { get; set; } public WildcardRegistry Wildcards { get; set; } = new(); + // Event flags for event-based fan types (杠上开花/海底捞月/抢杠胡/天胡/地胡) + public bool IsKongDraw { get; set; } // 杠后摸牌 — set after kong draw, cleared on next discard + public bool IsLastTile { get; set; } // 海底 — set when deck has 1 tile before draw + public bool IsRobbedKong { get; set; } // 抢杠 — set when someone adds to pung (bu_kong) + public bool IsFirstTurn { get; set; } // 第一轮 — set for dealer after deal, non-dealer after first draw + public string? DealerOnFirstTurn { get; set; } // 庄家首轮(用于天胡判断) + public MahjongGameState Clone() { return new MahjongGameState @@ -59,7 +66,12 @@ public class MahjongGameState RecentEvents = new List(RecentEvents), FlowerReplaced = FlowerReplaced, LastDrawnTile = LastDrawnTile, - Wildcards = Wildcards.Clone() + Wildcards = Wildcards.Clone(), + IsKongDraw = IsKongDraw, + IsLastTile = IsLastTile, + IsRobbedKong = IsRobbedKong, + IsFirstTurn = IsFirstTurn, + DealerOnFirstTurn = DealerOnFirstTurn }; } diff --git a/RuleEngine/Dsl/DslLoader.cs b/RuleEngine/Dsl/DslLoader.cs index 6dfbafa..9990403 100644 --- a/RuleEngine/Dsl/DslLoader.cs +++ b/RuleEngine/Dsl/DslLoader.cs @@ -62,9 +62,14 @@ public class CapabilityRegistry var enginePatterns = new HashSet { "清一色","混一色","字一色","对对胡","碰碰胡","碰碰和","暗七对","七对", - "带幺九","全带幺","混幺九","缺一门","平胡","断幺九", + "带幺九","全带幺","混幺九","缺一门","平胡","平和","断幺九", "全大","全中","全小","全双","大于五","小于五", - "十三幺","全不靠","一色双龙会" // special patterns from CheckWin + "十三幺","全不靠","一色双龙会","连七对", + "将一色","大四喜","大三元","小四喜","小三元", + "一色四同顺","五门齐","全求人","门前清", + "鸡胡","癞子胡", + "杠上开花","海底捞月","抢杠胡","天胡","地胡", + "将","无字" // base_fan:0/1 markers }; foreach (var f in rules.FanTypes) { @@ -72,10 +77,11 @@ public class CapabilityRegistry warnings.Add($"番型「{f.Name}」(base_fan:{f.BaseFan})引擎无识别逻辑→得分始终为0"); } - // Fan condition strings + // Fan condition strings (only warn for ones engine can't evaluate) + var knownConditions = new HashSet { "hand_contains_wildcard", "can_win_tumo_and_fan_ge_8", "can_win_and_fan_ge_8" }; foreach (var f in rules.FanTypes.Where(f => !string.IsNullOrEmpty(f.Condition))) { - if (f.Condition != "hand_contains_wildcard") + if (!knownConditions.Contains(f.Condition)) warnings.Add($"番型「{f.Name}」条件「{f.Condition}」引擎无法评估→番型永不触发"); } @@ -84,9 +90,8 @@ public class CapabilityRegistry && rules.WildcardRules.Behavior != "substitute") warnings.Add($"wildcard_rules.behavior={rules.WildcardRules.Behavior} 引擎仅支持substitute→忽略"); - // Fu flag (过水) - if (rules.Phases.Any(p => p.FuFlag != null)) - warnings.Add("过水(fu_flag)机制未实现→吃碰杠后pass仍可胡同一张牌"); + // Fu flag (过水) — now implemented + // No warning needed // Pre-hooks var knownHooks = new HashSet { "wildcard_count", "check_hua_zhu", "check_ting" }; diff --git a/RuleEngine/MahjongRoom.cs b/RuleEngine/MahjongRoom.cs index be8f72a..e23430c 100644 --- a/RuleEngine/MahjongRoom.cs +++ b/RuleEngine/MahjongRoom.cs @@ -161,6 +161,7 @@ public class MahjongRoom // Phase 2: Draw card (skip for dealer on first turn — they already have 14) bool isDealerFirstTurn = (player == State.Dealer && State.RoundNumber == 0); + State.IsLastTile = State.Deck.Count == 1; // 海底: deck has exactly 1 tile left if (isDealerFirstTurn) State.LastDrawnTile = null; else @@ -223,6 +224,8 @@ public class MahjongRoom int wildInHand = CountWildcardsInHand(fullHand); var result = _solver.CheckWin(fullHand, wildcardCount: wildInHand, require258Pair: r258); + if (result != null) + result.Fans = _solver.IdentifyFans(result, State); // event-based fans State.HuPlayers.Add(player); State.AlivePlayers.Remove(player); State.AddEvent("win", player, null, @@ -249,6 +252,12 @@ public class MahjongRoom State.LastDiscard = tile.Value; State.LastDiscardPlayer = player; RecordDiscard(player, tile.Value); + // Clear event flags after a normal discard + State.IsKongDraw = false; + State.IsRobbedKong = false; + State.FuFlags[player] = false; // clear fu_flag on own discard + if (State.IsFirstTurn && player == State.Dealer) + State.IsFirstTurn = false; // dealer's first turn complete State.AddEvent("discard", player, tile.Value, $"出牌: {MahjongTile.ToString(tile.Value)}"); } @@ -319,7 +328,10 @@ public class MahjongRoom var winResult = _solver.CheckWin(fullHand, wildcardCount: wildInHand, require258Pair: r258); if (winResult != null && winResult.IsWin) + { + winResult.Fans = _solver.IdentifyFans(winResult, State); // event-based fans canWin = true; + } // Human player: ask what they want to do if (_human != null && p == _human.Name && (canWin || canKong || canPung || canChi)) @@ -341,6 +353,12 @@ public class MahjongRoom if (input == "h" && canWin) { + // Check fu_flag: if player passed a win earlier, block it + if (State.FuFlags.GetValueOrDefault(p, false)) + { + Console.WriteLine(" ⚠ 过水——本轮不能胡同一张牌"); + continue; + } State.DiscardPool.Remove(discardTile); State.HuPlayers.Add(p); State.AlivePlayers.Remove(p); @@ -363,7 +381,8 @@ public class MahjongRoom { return ExecuteChi(p, discardTile); } - // Human passed — continue to next player + // Human passed — set fu_flag if they could have won + if (canWin) State.FuFlags[p] = true; continue; } @@ -555,6 +574,7 @@ public class MahjongRoom State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[player].Add(drawn); State.LastDrawnTile = drawn; + State.IsKongDraw = true; // 杠后摸牌标志 State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); } @@ -581,6 +601,7 @@ public class MahjongRoom pung.Type = "kong_bu"; pung.Tiles.Add(tile); State.AddEvent("bu_kong", player, tile, $"加杠!{MahjongTile.ToString(tile)}"); + State.IsRobbedKong = true; // 抢杠标志 if (State.Deck.Count > 0) { @@ -588,6 +609,7 @@ public class MahjongRoom State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[player].Add(drawn); State.LastDrawnTile = drawn; + State.IsKongDraw = true; State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); } @@ -720,6 +742,10 @@ public class MahjongRoom State.AddEvent("deal_done", "", null, "发牌完成"); + // Set first-turn flags for event-based fans + State.IsFirstTurn = true; + State.DealerOnFirstTurn = State.Dealer; + // Replace flowers in initial hands BEFORE game starts (e.g. Guangdong 鸡平胡) if (_rules.FlowerRules != null && includeFlowers && string.Equals(_rules.FlowerRules.ReplaceTiles, "BEFORE_GAME_START", StringComparison.OrdinalIgnoreCase)) diff --git a/RuleEngine/Patterns/MeldsSolver.cs b/RuleEngine/Patterns/MeldsSolver.cs index 62c8128..2936d71 100644 --- a/RuleEngine/Patterns/MeldsSolver.cs +++ b/RuleEngine/Patterns/MeldsSolver.cs @@ -584,7 +584,7 @@ public class MeldsSolver } // === 番型识别 (DSL-driven: structural patterns auto-detected) === - public List IdentifyFans(MeldsResult result) + public List IdentifyFans(MeldsResult result, MahjongGameState? state = null) { var fans = new List(); var melds = result.Melds; @@ -598,7 +598,7 @@ public class MeldsSolver if (allTiles.Count == 0) return fans; - // === Structural pattern recognizers (always evaluated regardless of config) === + // === Structural pattern recognizers === // 清一色: all tiles same suit (numbered suits only) var suits = allTiles.Where(t => MahjongTile.IsNumbered(t)) @@ -608,22 +608,29 @@ public class MeldsSolver fans.Add("清一色"); // 混一色: one suit + honors - if (suits.Count == 1 && suits[0] < 3 && hasHonors && allTiles.Any(MahjongTile.IsHonor)) + if (suits.Count == 1 && suits[0] < 3 && hasHonors) fans.Add("混一色"); // 字一色: all honors if (allTiles.All(MahjongTile.IsHonor)) fans.Add("字一色"); - // 对对胡: all melds are kezi + // 对对胡 / 碰碰胡 / 碰碰和: all melds are kezi if (melds.Count > 0 && melds.All(m => m.Type == "kezi")) fans.Add(FanName("对对胡", "碰碰胡", "碰碰和")); - // 暗七对 (all melds are pairs — from seven-pairs path) + // 暗七对 / 七对 (from seven-pairs path) if (melds.Count > 0 && melds.All(m => m.Type == "pair")) fans.Add(FanName("暗七对", "七对")); - // 带幺九: all melds contain 1/9/honor + // 将一色: all tiles are 2/5/8 + if (allTiles.All(t => MahjongTile.IsNumbered(t))) + { + bool all258 = allTiles.All(t => { int r = MahjongTile.Rank(t); return r == 2 || r == 5 || r == 8; }); + if (all258) fans.Add("将一色"); + } + + // 带幺九: all melds contain 1/9/honor, pair is terminal if (melds.Count > 0 && pair.Count >= 1) { bool allMeldsTerminal = melds.All(m => @@ -633,7 +640,7 @@ public class MeldsSolver fans.Add(FanName("带幺九", "全带幺")); } - // 混幺九: all tiles are terminals or honors, includes at least one honor and one numbered + // 混幺九: all tiles are terminals or honors, has both if (melds.Count > 0 && pair.Count >= 1) { bool allTerminalOrHonor = allTiles.All(t => MahjongTile.IsTerminal(t) || MahjongTile.IsHonor(t)); @@ -648,59 +655,113 @@ public class MeldsSolver if (numberedSuits <= 2 && allTiles.Any(t => MahjongTile.IsNumbered(t))) fans.Add("缺一门"); - // 平胡 (all shunzi, non-258 pair, non-single-wait) + // 平胡 / 平和: all shunzi if (melds.Count > 0 && melds.All(m => m.Type == "shunzi")) - fans.Add("平胡"); - - // 全带幺 (every meld and pair contains a terminal or honor) — same as 带幺九 - // Already covered above as 带幺九 + fans.Add(FanName("平胡", "平和")); // 断幺九: no terminals or honors - bool noTerminals = allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t)); - if (noTerminals && allTiles.Count > 0) + if (allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t))) fans.Add("断幺九"); - // 全大 (all ranks 7-9) - bool allBig = allTiles.All(t => - MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7); - if (allBig && allTiles.All(MahjongTile.IsNumbered)) + // 无字: all tiles are numbered (no honors) + if (!hasHonors && allTiles.All(t => MahjongTile.IsNumbered(t))) + fans.Add("无字"); + + // 全大 (ranks 7-9) + if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7)) fans.Add("全大"); - - // 全中 (all ranks 4-6) - bool allMid = allTiles.All(t => - MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6); - if (allMid && allTiles.All(MahjongTile.IsNumbered)) + // 全中 (ranks 4-6) + if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6)) fans.Add("全中"); - - // 全小 (all ranks 1-3) - bool allSmall = allTiles.All(t => - MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3); - if (allSmall && allTiles.All(MahjongTile.IsNumbered)) + // 全小 (ranks 1-3) + if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3)) fans.Add("全小"); - - // 大于五 (all ranks > 5) - bool allGreater5 = allTiles.All(t => - MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5); - if (allGreater5 && allTiles.All(MahjongTile.IsNumbered)) + // 大于五 + if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5)) fans.Add("大于五"); - - // 小于五 (all ranks < 5) - bool allLess5 = allTiles.All(t => - MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5); - if (allLess5 && allTiles.All(MahjongTile.IsNumbered)) + // 小于五 + if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5)) fans.Add("小于五"); - - // 全双 (all ranks even) - bool allEven = allTiles.All(t => - !MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0); - if (allEven && allTiles.All(t => MahjongTile.IsNumbered(t) || MahjongTile.IsHonor(t))) + // 全双 (all even) + if (allTiles.All(t => !MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0)) fans.Add("全双"); + // 大四喜: 4 wind kongs/pungs (东31,南32,西33,北34) + var keziTiles = melds.Where(m => m.Type == "kezi").SelectMany(m => m.Tiles).Where(t => t > 0); + int windKezi = keziTiles.Where(t => t >= 31 && t <= 34).GroupBy(t => t).Count(g => g.Count() >= 3); + if (windKezi == 4) fans.Add("大四喜"); + + // 大三元: 3 dragon kongs/pungs (中35,发36,白37) + int dragonKezi = keziTiles.Where(t => t >= 35 && t <= 37).GroupBy(t => t).Count(g => g.Count() >= 3); + if (dragonKezi == 3) fans.Add("大三元"); + + // 小四喜: 3 wind kezi + wind pair + var pairTiles = pair.Where(t => t > 0); + bool windPair = pairTiles.All(t => t >= 31 && t <= 34) && pairTiles.Distinct().Count() == 1; + if (windKezi == 3 && windPair && pairTiles.Any(t => t >= 31 && t <= 34)) + fans.Add("小四喜"); + + // 小三元: 2 dragon kezi + dragon pair + bool dragonPair = pairTiles.All(t => t >= 35 && t <= 37) && pairTiles.Distinct().Count() == 1; + if (dragonKezi == 2 && dragonPair && pairTiles.Any(t => t >= 35 && t <= 37)) + fans.Add("小三元"); + + // 一色四同顺: four identical sequences in same suit + var shunziGroups = melds.Where(m => m.Type == "shunzi") + .GroupBy(m => string.Join(",", m.Tiles.OrderBy(t => t))); + if (shunziGroups.Any(g => g.Count() >= 4)) + fans.Add("一色四同顺"); + + // 五门齐: all 5 tile categories present (3 suits + honors + at least one terminal) + var categories = new HashSet(); + foreach (var t in allTiles) + { + if (MahjongTile.IsHonor(t)) categories.Add(3); + else categories.Add(MahjongTile.Suit(t)); + } + if (categories.Count >= 4) // 3 suits + honors = 4 categories means 五门齐 + fans.Add("五门齐"); + + // 全求人: all 4 melds exposed (allTiles = pair only, or 2 tiles) + if (state != null && melds.Count == 0 && allTiles.Count <= 2) + fans.Add("全求人"); + + // 门前清: no exposed melds + if (state != null) + { + if (state.Exposed.All(kv => kv.Value.Count == 0)) + fans.Add("门前清"); + } + + // 癞子胡: hand contains wildcard (condition from DSL) + if (state != null && state.Wildcards.CountWildcards(allTiles) > 0) + fans.Add("癞子胡"); + + // Event-based fans (require state) + if (state != null) + { + if (state.IsKongDraw) fans.Add("杠上开花"); + if (state.IsLastTile) fans.Add("海底捞月"); + if (state.IsRobbedKong) fans.Add("抢杠胡"); + if (state.IsFirstTurn && state.DealerOnFirstTurn != null) + fans.Add("天胡"); + else if (state.IsFirstTurn) + fans.Add("地胡"); + } + + // 鸡胡: base-level fan when no other structural fan matches + if (fans.Count == 0) + fans.Add("鸡胡"); + // Filter: only keep fans that exist in DSL config or are well-known return fans.Where(f => _fanConfig.ContainsKey(f) || f is "清一色" or "混一色" or "对对胡" or "碰碰和" or "暗七对" or "七对" - or "带幺九" or "十三幺" or "字一色" or "断幺九" - or "缺一门" or "平胡" or "全大" or "全中" or "全小" or "全双").ToList(); + or "带幺九" or "十三幺" or "字一色" or "断幺九" or "无字" or "将" + or "缺一门" or "平胡" or "全大" or "全中" or "全小" or "全双" + or "将一色" or "大四喜" or "大三元" or "小四喜" or "小三元" + or "一色四同顺" or "五门齐" or "全求人" or "门前清" + or "鸡胡" or "癞子胡" or "平和" + or "杠上开花" or "海底捞月" or "抢杠胡" or "天胡" or "地胡").ToList(); } // === 番型互斥应用 ===