namespace RuleEngine.Patterns; using RuleEngine.Core; public class FanConfig { public string Name { get; set; } = ""; public int BaseFan { get; set; } public int Level { get; set; } public List Excludes { get; set; } = new(); public List Conflicts { get; set; } = new(); public string? Condition { get; set; } } public class MeldsSolver { private readonly Dictionary _fanConfig; private readonly WildcardRegistry _wildcards; public MeldsSolver(Dictionary fanConfig, WildcardRegistry? wildcards = null) { _fanConfig = fanConfig; _wildcards = wildcards ?? new(); } // === 主入口 === public MeldsResult CheckWin(List hand, int? newTile = null, int wildcardCount = 0, bool require258Pair = false) { var tiles = new List(hand); if (newTile.HasValue) tiles.Add(newTile.Value); // Standard mahjong hand: 4 melds (3 tiles each) + 1 pair (2 tiles) = 14 tiles // With exposed melds, hand has fewer tiles. Valid counts: 2,5,8,11,14 // Fixed wildcards (e.g. 紅中) are IN the tiles list but skipped by BuildCounts. // Only count non-wildcard tiles for the guard. int wildcardsInTiles = tiles.Count(_wildcards.IsWildcard); int total = tiles.Count - wildcardsInTiles + wildcardCount; if (total < 2 || total % 3 != 2 || total > 14) return new MeldsResult { IsWin = false }; tiles.Sort(); // 1. 七对 (wildcards can pair) var sevenPairs = TrySevenPairs(tiles, wildcardCount); if (sevenPairs != null) { // 258将检查: 武汉七对每对都需258 if (require258Pair) { foreach (var m in sevenPairs.Melds) { if (m.Type == "pair" && m.Tiles.Count >= 2) { int a = m.Tiles[0], b = m.Tiles[1]; // Both tiles must be 258 or wildcard if (!Is258OrWildcard(a) && !Is258OrWildcard(b)) return new MeldsResult { IsWin = false }; } } } sevenPairs.Fans = new List { IsConsecutiveSameSuitSevenPairs(sevenPairs.Melds) ? "连七对" : FanName("暗七对", "七对") }; sevenPairs.Fans = ApplyFanExclusions( sevenPairs.Fans.Concat(IdentifyFans(sevenPairs)).Distinct().ToList()); return sevenPairs; } // 2. 十三幺 var thirteen = TryThirteenOrphans(tiles, wildcardCount); if (thirteen != null) { if (require258Pair) return new MeldsResult { IsWin = false }; // 十三幺无258将概念 thirteen.Fans = new List { "十三幺" }; thirteen.Fans = ApplyFanExclusions( thirteen.Fans.Concat(IdentifyFans(thirteen)).Distinct().ToList()); return thirteen; } // 3. 全不靠 var allOrphans = TryAllOrphans(tiles, wildcardCount); if (allOrphans != null) { if (require258Pair) return new MeldsResult { IsWin = false }; // 全不靠无将牌概念 allOrphans.Fans = new List { "全不靠" }; allOrphans.Fans = ApplyFanExclusions( allOrphans.Fans.Concat(IdentifyFans(allOrphans)).Distinct().ToList()); return allOrphans; } // 4. 一色双龙会 var doubleDragon = TryDoubleDragon(tiles, wildcardCount); if (doubleDragon != null) { if (require258Pair) return new MeldsResult { IsWin = false }; // 一色双龙会无258将概念 doubleDragon.Fans = new List { "一色双龙会" }; doubleDragon.Fans = ApplyFanExclusions( doubleDragon.Fans.Concat(IdentifyFans(doubleDragon)).Distinct().ToList()); return doubleDragon; } // 5. 标准回溯(含 wildcard 缺口填充) var counts = BuildCounts(tiles); var result = TryExtractMelds(counts, wildcardCount, 0); if (result != null) { // 258将检查 if (require258Pair && result.PairTiles.Count == 2) { if (!IsValid258Pair(result.PairTiles[0], result.PairTiles[1])) return new MeldsResult { IsWin = false }; } result.Fans = ApplyFanExclusions(IdentifyFans(result)); return result; } return new MeldsResult { IsWin = false }; } // === Counts array helper === private int[] BuildCounts(List tiles) { // Index: 万1-9→0-8, 条1-9→9-17, 筒1-9→18-26, // 字31-37→27-33, 花/宝牌不进counts var counts = new int[34]; foreach (var t in tiles) { if (_wildcards.IsWildcard(t) || MahjongTile.IsFlower(t)) continue; int idx = TileToIndex(t); if (idx >= 0 && idx < 34) counts[idx]++; } return counts; } private int TileToIndex(int tile) { if (tile >= 1 && tile <= 9) return tile - 1; if (tile >= 11 && tile <= 19) return 9 + (tile - 11); if (tile >= 21 && tile <= 29) return 18 + (tile - 21); if (tile >= 31 && tile <= 37) return 27 + (tile - 31); return -1; } private int IndexToTile(int idx) { if (idx < 9) return idx + 1; if (idx < 18) return 11 + (idx - 9); if (idx < 27) return 21 + (idx - 18); if (idx < 34) return 31 + (idx - 27); return -1; } // === 标准回溯(基础版,无 wildcard) === private MeldsResult? TryExtractMelds(int[] counts, int wildcardCount, int pairCount) { // Find first non-zero int i = 0; while (i < counts.Length && counts[i] == 0) i++; if (i == counts.Length) { // All non-wildcard tiles consumed return FinalizeWithWildcards(wildcardCount, pairCount); } int tile = IndexToTile(i); // Try kezi (triplet) if (counts[i] >= 3) { counts[i] -= 3; var r = TryExtractMelds(counts, wildcardCount, pairCount); if (r != null) { counts[i] += 3; r.Melds.Insert(0, new Meld { Type = "kezi", Tiles = new List { tile, tile, tile } }); return r; } counts[i] += 3; } // Try shunzi (sequence) - only for numbered tiles if (IsNumberedIndex(i) && i + 2 < 27 && SameSuitGroup(i, i + 2)) { if (counts[i] >= 1 && counts[i + 1] >= 1 && counts[i + 2] >= 1) { counts[i]--; counts[i + 1]--; counts[i + 2]--; var r = TryExtractMelds(counts, wildcardCount, pairCount); if (r != null) { counts[i]++; counts[i + 1]++; counts[i + 2]++; r.Melds.Insert(0, new Meld { Type = "shunzi", Tiles = new List { tile, IndexToTile(i + 1), IndexToTile(i + 2) } }); return r; } counts[i]++; counts[i + 1]++; counts[i + 2]++; } } // Try pair if (counts[i] >= 2 && pairCount == 0) { counts[i] -= 2; var r = TryExtractMelds(counts, wildcardCount, 1); if (r != null) { counts[i] += 2; r.PairTiles = new List { tile, tile }; return r; } counts[i] += 2; } // === Wildcard gap-fill (缺口填充) === // Try kezi with 1 wildcard if (counts[i] >= 2 && wildcardCount >= 1) { counts[i] -= 2; var r = TryExtractMelds(counts, wildcardCount - 1, pairCount); if (r != null) { counts[i] += 2; r.Melds.Insert(0, new Meld { Type = "kezi", Tiles = new List { tile, tile, -1 } }); r.WildcardsUsed++; return r; } counts[i] += 2; } // Try kezi with 2 wildcards if (counts[i] >= 1 && wildcardCount >= 2) { counts[i] -= 1; var r = TryExtractMelds(counts, wildcardCount - 2, pairCount); if (r != null) { counts[i] += 1; r.Melds.Insert(0, new Meld { Type = "kezi", Tiles = new List { tile, -1, -1 } }); r.WildcardsUsed += 2; return r; } counts[i] += 1; } // Shunzi with 1 wildcard: need [tile, wild, tile+2] if (IsNumberedIndex(i) && i + 2 < 27 && SameSuitGroup(i, i + 2) && wildcardCount >= 1) { if (counts[i] >= 1 && counts[i + 2] >= 1) { counts[i]--; counts[i + 2]--; var r = TryExtractMelds(counts, wildcardCount - 1, pairCount); if (r != null) { counts[i]++; counts[i + 2]++; r.Melds.Insert(0, new Meld { Type = "shunzi", Tiles = new List { tile, -1, IndexToTile(i + 2) } }); r.WildcardsUsed++; return r; } counts[i]++; counts[i + 2]++; } } // Shunzi with 1 wildcard: need [tile, tile+1, wild] if (IsNumberedIndex(i) && i + 2 < 27 && SameSuitGroup(i, i + 2) && wildcardCount >= 1) { if (counts[i] >= 1 && counts[i + 1] >= 1) { counts[i]--; counts[i + 1]--; var r = TryExtractMelds(counts, wildcardCount - 1, pairCount); if (r != null) { counts[i]++; counts[i + 1]++; r.Melds.Insert(0, new Meld { Type = "shunzi", Tiles = new List { tile, IndexToTile(i + 1), -1 } }); r.WildcardsUsed++; return r; } counts[i]++; counts[i + 1]++; } } // Pair with 1 wildcard if (counts[i] >= 1 && wildcardCount >= 1 && pairCount == 0) { counts[i] -= 1; var r = TryExtractMelds(counts, wildcardCount - 1, 1); if (r != null) { counts[i] += 1; r.PairTiles = new List { tile, -1 }; r.WildcardsUsed++; return r; } counts[i] += 1; } return null; } private MeldsResult? FinalizeWithWildcards(int wildcardCount, int pairCount) { if (pairCount == 0) { // Need a pair — use 2 wildcards if (wildcardCount >= 2) { return new MeldsResult { IsWin = true, Melds = new List(), PairTiles = new List { -1, -1 }, WildcardsUsed = 2 }; } return null; } // All tiles consumed, pair exists. Remaining wildcards form extra melds if any. // For simplicity: any remaining wildcards in multiples of 3 form kezi int remaining = wildcardCount; var extraMelds = new List(); while (remaining >= 3) { extraMelds.Add(new Meld { Type = "kezi", Tiles = new List { -1, -1, -1 } }); remaining -= 3; } return new MeldsResult { IsWin = true, Melds = extraMelds, PairTiles = new List(), WildcardsUsed = wildcardCount - remaining }; } private bool SameSuitGroup(int idxA, int idxB) { // Both must be in same numbered suit group (0-8, 9-17, 18-26) return (idxA / 9) == (idxB / 9) && idxA < 27 && idxB < 27; } private bool IsNumberedIndex(int idx) => idx < 27; // === 七对 === private MeldsResult? TrySevenPairs(List tiles, int wildcardCount) { // Count non-wildcard tiles var counts = new Dictionary(); foreach (var t in tiles) { if (_wildcards.IsWildcard(t)) continue; if (MahjongTile.IsFlower(t)) return null; // 七对不能有花牌 counts[t] = counts.GetValueOrDefault(t) + 1; } int needWildcards = 0; foreach (var (_, c) in counts) { if (c % 2 == 1) needWildcards++; // need 1 wildcard to complete this pair } if (needWildcards <= wildcardCount) { var melds = new List(); foreach (var (t, c) in counts) { int pairs = c / 2; for (int p = 0; p < pairs; p++) melds.Add(new Meld { Type = "pair", Tiles = new List { t, t } }); } return new MeldsResult { IsWin = true, Melds = melds, PairTiles = melds.LastOrDefault()?.Tiles ?? new List(), WildcardsUsed = needWildcards }; } return null; } /// Check if 7 pairs form 连七对: same suit, consecutive ranks 1-7/2-8/3-9. private static bool IsConsecutiveSameSuitSevenPairs(List melds) { if (melds.Count != 7) return false; var tiles = melds.Select(m => m.Tiles[0]).ToList(); // All must be numbered (no honors) and same suit if (!tiles.All(MahjongTile.IsNumbered)) return false; int suit = MahjongTile.Suit(tiles[0]); if (!tiles.All(t => MahjongTile.Suit(t) == suit)) return false; // Ranks must form consecutive 1-7, 2-8, or 3-9 var ranks = tiles.Select(MahjongTile.Rank).OrderBy(r => r).ToList(); return ranks.SequenceEqual(Enumerable.Range(ranks[0], 7)) && ranks[0] <= 3; } // === 十三幺 === private MeldsResult? TryThirteenOrphans(List tiles, int wildcardCount) { // 13 unique terminal/honor tiles: 1万,9万,1条,9条,1筒,9筒 + 7字牌 = 13 // + 1 duplicate = 14 int[] required = { 1, 9, 11, 19, 21, 29, 31, 32, 33, 34, 35, 36, 37 }; int present = 0; int extra = 0; // duplicate of required tiles foreach (var t in tiles) { if (_wildcards.IsWildcard(t)) continue; if (MahjongTile.IsFlower(t)) return null; if (required.Contains(t)) { if (HasBit(present, t)) extra++; else present = SetBit(present, t); } else return null; // non-terminal tile found } int unique = PopCount(present); int missing = 13 - unique; // Need wildcards for: missing unique tiles + 1 for the pair (if no duplicate exists) int neededForMissing = missing; int neededForPair = extra > 0 ? 0 : 1; // duplicate already present → no pair wildcard needed int totalNeeded = neededForMissing + neededForPair; if (totalNeeded <= wildcardCount) { return new MeldsResult { IsWin = true, Melds = new List(), PairTiles = new List(), WildcardsUsed = totalNeeded }; } return null; } private static bool HasBit(int bits, int tile) { int idx = tile switch { 1 => 0, 9 => 1, 11 => 2, 19 => 3, 21 => 4, 29 => 5, 31 => 6, 32 => 7, 33 => 8, 34 => 9, 35 => 10, 36 => 11, 37 => 12, _ => -1 }; return idx >= 0 && (bits & (1 << idx)) != 0; } private static int SetBit(int bits, int tile) { int idx = tile switch { 1 => 0, 9 => 1, 11 => 2, 19 => 3, 21 => 4, 29 => 5, 31 => 6, 32 => 7, 33 => 8, 34 => 9, 35 => 10, 36 => 11, 37 => 12, _ => -1 }; return idx >= 0 ? bits | (1 << idx) : bits; } private static int PopCount(int bits) { int count = 0; while (bits != 0) { count++; bits &= bits - 1; } return count; } // === 全不靠 (All Orphans) === public MeldsResult? TryAllOrphans(List tiles, int wildcardCount) { // 147, 258, 369 distribution + honors // All numbered tiles and honors are allowed, flowers are rejected. foreach (var t in tiles) { if (_wildcards.IsWildcard(t)) continue; if (MahjongTile.IsFlower(t)) return null; if (!MahjongTile.IsNumbered(t) && !MahjongTile.IsHonor(t)) return null; } // Check 147/258/369 pattern within each suit var suitTiles = new Dictionary>(); var honors = new List(); foreach (var t in tiles) { if (_wildcards.IsWildcard(t)) continue; if (MahjongTile.IsHonor(t)) honors.Add(t); else { int s = MahjongTile.Suit(t); if (!suitTiles.ContainsKey(s)) suitTiles[s] = new List(); suitTiles[s].Add(t); } } // Each suit group must be in 147, 258, or 369 only int gapCount = 0; foreach (var (_, st) in suitTiles) { var ranks = st.Select(MahjongTile.Rank).Distinct().OrderBy(r => r).ToList(); // Check ranks are all in same "gap-3" group var groups = ranks.GroupBy(r => (r - 1) % 3); if (groups.Count() > 1) return null; gapCount += ranks.Count; } // 全不靠: 16 possible positions (9 numbered gap slots + 7 honors) // Hand has 14 tiles, so 2 positions can be missing even with all real tiles fitting. // Tolerance: can miss up to (16-14)=2 positions without wildcards, + wildcards int missingSlots = 9 - gapCount; int honorNeeded = 7 - honors.Distinct().Count(); if (honorNeeded < 0) honorNeeded = 0; int totalMissing = missingSlots + honorNeeded; if (totalMissing <= wildcardCount + 2) { return new MeldsResult { IsWin = true, Melds = new List(), PairTiles = new List(), WildcardsUsed = Math.Min(totalMissing, wildcardCount) }; } return null; } // === 一色双龙会 === public MeldsResult? TryDoubleDragon(List tiles, int wildcardCount) { // All same numbered suit, ranks 1-9 each at least 2 copies var nonWildTiles = tiles.Where(t => !_wildcards.IsWildcard(t) && !MahjongTile.IsFlower(t)).ToList(); if (nonWildTiles.Count == 0) return null; // Double dragon only applies to numbered tiles (not honors/flowers) if (nonWildTiles.Any(t => !MahjongTile.IsNumbered(t))) return null; int suit = MahjongTile.Suit(nonWildTiles[0]); if (suit >= 3) return null; // honor/flowers can't form double dragon if (nonWildTiles.Any(t => MahjongTile.Suit(t) != suit)) return null; var rankCounts = new int[10]; // 1-indexed for ranks 1-9 foreach (var t in nonWildTiles) { int r = MahjongTile.Rank(t); if (r >= 1 && r <= 9) rankCounts[r]++; } int wildcardsAvailable = wildcardCount + tiles.Count(_wildcards.IsWildcard); for (int r = 1; r <= 9; r++) { if (rankCounts[r] < 2) { int need = 2 - rankCounts[r]; if (wildcardsAvailable >= need) wildcardsAvailable -= need; else return null; } } return new MeldsResult { IsWin = true, Melds = new List(), PairTiles = new List(), WildcardsUsed = wildcardCount + tiles.Count(_wildcards.IsWildcard) - wildcardsAvailable }; } // === 258将检查 === private bool IsValid258Pair(int tileA, int tileB) { // Both wildcards → OK. One wildcard → check the other. Both real → both must be 258. if (tileA == -1 && tileB == -1) return true; if (tileA == -1) return Is258Tile(tileB); if (tileB == -1) return Is258Tile(tileA); return Is258Tile(tileA) && Is258Tile(tileB); } private bool Is258OrWildcard(int tile) => tile == -1 || Is258Tile(tile); private bool Is258Tile(int tile) { int r = MahjongTile.Rank(tile); return r == 2 || r == 5 || r == 8; } /// Resolve canonical fan name to DSL fan name (e.g. 对对胡→碰碰胡/碰碰和). private string FanName(params string[] candidates) { foreach (var c in candidates) if (_fanConfig.ContainsKey(c)) return c; return candidates[0]; } // === 番型识别 (DSL-driven: structural patterns auto-detected) === public List IdentifyFans(MeldsResult result, MahjongGameState? state = null) { var fans = new List(); var melds = result.Melds; var pair = result.PairTiles; // Collect all non-wildcard tiles var allTiles = melds .SelectMany(m => m.Tiles.Where(t => t != -1)) .Concat(pair.Where(t => t != -1)) .ToList(); if (allTiles.Count == 0) return fans; // === Structural pattern recognizers === // 清一色: all tiles same suit (numbered suits only) var suits = allTiles.Where(t => MahjongTile.IsNumbered(t)) .Select(MahjongTile.Suit).Distinct().ToList(); bool hasHonors = allTiles.Any(MahjongTile.IsHonor); if (suits.Count == 1 && suits[0] < 3 && !hasHonors) fans.Add("清一色"); // 混一色: one suit + honors if (suits.Count == 1 && suits[0] < 3 && hasHonors) fans.Add("混一色"); // 字一色: all honors if (allTiles.All(MahjongTile.IsHonor)) fans.Add("字一色"); // 对对胡 / 碰碰胡 / 碰碰和: all melds are kezi if (melds.Count > 0 && melds.All(m => m.Type == "kezi")) fans.Add(FanName("对对胡", "碰碰胡", "碰碰和")); // 暗七对 / 七对 (from seven-pairs path) if (melds.Count > 0 && melds.All(m => m.Type == "pair")) fans.Add(FanName("暗七对", "七对")); // 将一色: 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 => m.Tiles.Where(t => t != -1).All(MahjongTile.IsTerminal)); bool pairTerminal = pair.All(t => t == -1 || MahjongTile.IsTerminal(t)); if (allMeldsTerminal && pairTerminal) fans.Add(FanName("带幺九", "全带幺")); } // 混幺九: 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)); bool hasNumberedTerminal = allTiles.Any(t => MahjongTile.IsNumbered(t) && MahjongTile.IsTerminal(t)); if (allTerminalOrHonor && hasHonors && hasNumberedTerminal) fans.Add("混幺九"); } // 缺一门: numbered tiles from at most 2 suits var numberedSuits = allTiles.Where(t => MahjongTile.IsNumbered(t)) .Select(MahjongTile.Suit).Distinct().Count(); if (numberedSuits <= 2 && allTiles.Any(t => MahjongTile.IsNumbered(t))) fans.Add("缺一门"); // 平胡 / 平和: all shunzi if (melds.Count > 0 && melds.All(m => m.Type == "shunzi")) fans.Add(FanName("平胡", "平和")); // 断幺九: no terminals or honors if (allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t))) fans.Add("断幺九"); // 无字: 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("全大"); // 全中 (ranks 4-6) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6)) fans.Add("全中"); // 全小 (ranks 1-3) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3)) fans.Add("全小"); // 大于五 if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5)) fans.Add("大于五"); // 小于五 if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5)) fans.Add("小于五"); // 全双 (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("大四喜"); if (windKezi == 3) 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("大三元"); if (dragonKezi == 2) 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("小三元"); // 一色四同顺/一色三同顺/一般高: grouped by identical sequences 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("一色四同顺"); foreach (var g in shunziGroups) { if (g.Count() >= 3) fans.Add("一色三同顺"); if (g.Count() >= 2) 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("地胡"); // 圈风刻: kezi matching current wind round (e.g. 东风圈→有东刻子) if (_fanConfig.ContainsKey("圈风") && keziTiles.Any(t => t == state.WindRound)) fans.Add("圈风"); // 门风刻: kezi matching player's seat wind if (_fanConfig.ContainsKey("门风") && state.SeatWinds.Values.Any(sw => keziTiles.Any(t => t == sw))) fans.Add("门风"); // 单钓将: detected at self-draw time (single tile→pair completion) if (_fanConfig.ContainsKey("单钓将") && state.IsSingleWait) fans.Add("单钓将"); } // 四归一: 4 identical numbered tiles used across 2+ melds (not in a single kong) if (_fanConfig.ContainsKey("四归一")) { var tileCounts4 = allTiles.Where(t => MahjongTile.IsNumbered(t)).GroupBy(t => t); bool hasSiGuiYi = tileCounts4.Any(g => g.Count() >= 4 && !melds.Any(m => m.Type == "kezi" && m.Tiles.Contains(g.Key) && m.Tiles.All(x => x == g.Key))); if (hasSiGuiYi) fans.Add("四归一"); } // 鸡胡: base-level fan when no other fan with positive value exists. // A fan has positive value if it's in _fanConfig with base_fan>0, // or in the hardcoded fallback list (清一色/混一色/对对胡/碰碰和/暗七对/七对/带幺九/十三幺/鸡胡). bool allZeroValue = fans.Count > 0 && fans.All(f => { if (_fanConfig.TryGetValue(f, out var fc)) return fc.BaseFan <= 0; // No DSL config → check if hardcoded fallback gives a value return f is not ("清一色" or "混一色" or "对对胡" or "碰碰和" or "暗七对" or "七对" or "带幺九" or "十三幺" or "鸡胡"); }); if (fans.Count == 0 || allZeroValue) fans.Add("鸡胡"); // Filter: keep fans in _fanConfig OR event fans OR well-known structural fans // Exclude zero-value markers (无字/缺一门/将) and no-fallback fans (断幺九/平胡/平和) // → these trigger allZeroValue→鸡胡 to ensure non-zero scoring return fans.Where(f => _fanConfig.ContainsKey(f) || f is "杠上开花" 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 "一般高" or "五门齐" or "全求人" or "门前清" or "三风" or "双箭刻" or "圈风" or "门风" or "单钓将" or "四归一" or "连七对" or "全不靠" or "一色双龙会").ToList(); } // === 番型互斥应用 === public List ApplyFanExclusions(List fans) { var toRemove = new HashSet(); foreach (var fan in fans) { if (_fanConfig.TryGetValue(fan, out var def)) { if (def.Excludes != null) foreach (var excluded in def.Excludes) toRemove.Add(excluded); if (def.Conflicts != null) { foreach (var conflict in def.Conflicts) { if (fans.Contains(conflict)) { // Keep the higher fan if (_fanConfig.TryGetValue(conflict, out var def2)) { if (def2.BaseFan > def.BaseFan) toRemove.Add(fan); else toRemove.Add(conflict); } } } } } } return fans.Where(f => !toRemove.Contains(f)).ToList(); } }