Files
card-game-engine/RuleEngine/Patterns/MeldsSolver.cs
xiaoou 2ef7d57877 fix: 连七对识别器 + 258检查gap + .First() null防御
1. 连七对: IsConsecutiveSameSuitSevenPairs() 检测同色连续7对
   TrySevenPairs成功后检查→ 连七对 vs 暗七对/七对
2. 258 check gap: 十三幺/全不靠/一色双龙会路径加 require258Pair 检查
   武汉麻将中这些牌型无258将概念→258要求下直接拒绝
3. .First() → .FirstOrDefault() + null return:
   ExecuteAnKong / ExecuteBuKong 防御性编程

审计JSON: remaining_issues 清空 (所有已知问题已修复)
2026-07-04 22:49:19 +08:00

829 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> Excludes { get; set; } = new();
public List<string> Conflicts { get; set; } = new();
public string? Condition { get; set; }
}
public class MeldsSolver
{
private readonly Dictionary<string, FanConfig> _fanConfig;
private readonly WildcardRegistry _wildcards;
public MeldsSolver(Dictionary<string, FanConfig> fanConfig, WildcardRegistry? wildcards = null)
{
_fanConfig = fanConfig;
_wildcards = wildcards ?? new();
}
// === 主入口 ===
public MeldsResult CheckWin(List<int> hand, int? newTile = null,
int wildcardCount = 0, bool require258Pair = false)
{
var tiles = new List<int>(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<string> {
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<string> { "十三幺" };
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<string> { "全不靠" };
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<string> { "一色双龙会" };
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<int> 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<int> { 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<int> { 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<int> { 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<int> { 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<int> { 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<int> { 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<int> { 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<int> { 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<Meld>(),
PairTiles = new List<int> { -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<Meld>();
while (remaining >= 3)
{
extraMelds.Add(new Meld { Type = "kezi", Tiles = new List<int> { -1, -1, -1 } });
remaining -= 3;
}
return new MeldsResult
{
IsWin = true,
Melds = extraMelds,
PairTiles = new List<int>(),
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<int> tiles, int wildcardCount)
{
// Count non-wildcard tiles
var counts = new Dictionary<int, int>();
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<Meld>();
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<int> { t, t } });
}
return new MeldsResult
{
IsWin = true,
Melds = melds,
PairTiles = melds.LastOrDefault()?.Tiles ?? new List<int>(),
WildcardsUsed = needWildcards
};
}
return null;
}
/// <summary>Check if 7 pairs form 连七对: same suit, consecutive ranks 1-7/2-8/3-9.</summary>
private static bool IsConsecutiveSameSuitSevenPairs(List<Meld> 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<int> 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<Meld>(),
PairTiles = new List<int>(),
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<int> 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<int, List<int>>();
var honors = new List<int>();
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<int>();
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<Meld>(),
PairTiles = new List<int>(),
WildcardsUsed = Math.Min(totalMissing, wildcardCount)
};
}
return null;
}
// === 一色双龙会 ===
public MeldsResult? TryDoubleDragon(List<int> 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<Meld>(),
PairTiles = new List<int>(),
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;
}
/// <summary>Resolve canonical fan name to DSL fan name (e.g. 对对胡→碰碰胡/碰碰和).</summary>
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<string> IdentifyFans(MeldsResult result, MahjongGameState? state = null)
{
var fans = new List<string>();
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("大四喜");
// 大三元: 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<int>();
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 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: 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 "全中" or "全小" or "全双"
or "将一色" or "大四喜" or "大三元" or "小四喜" or "小三元"
or "一色四同顺" or "五门齐" or "全求人" or "门前清"
or "鸡胡" or "癞子胡" or "平和"
or "杠上开花" or "海底捞月" or "抢杠胡" or "天胡" or "地胡").ToList();
}
// === 番型互斥应用 ===
public List<string> ApplyFanExclusions(List<string> fans)
{
var toRemove = new HashSet<string>();
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();
}
}