Files
card-game-engine/RuleEngine/Patterns/MeldsSolver.cs
xiaoou 70b00d5e6d fix: IdentifyFans filter排除无fallback番型—防止跨变体越权
核心变更:
- filter从'well-known全放行'改为'只放行_fanConfig或有fallback值的'
- 排除: 断幺九/无字/缺一门/将/平胡/平和(无hardcoded fallback)
- 这些被排除时→allZeroValue→鸡胡兜底→保证非零分

效果: 南昌不再显示'断幺九+无字+平胡'等不存在于南昌规则的番型
测试: _fanConfig补全16个番型, 58 tests pass
2026-07-05 01:09:42 +08:00

856 lines
32 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("大四喜");
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<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("地胡");
// 圈风刻: 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<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();
}
}