1. 连七对: IsConsecutiveSameSuitSevenPairs() 检测同色连续7对 TrySevenPairs成功后检查→ 连七对 vs 暗七对/七对 2. 258 check gap: 十三幺/全不靠/一色双龙会路径加 require258Pair 检查 武汉麻将中这些牌型无258将概念→258要求下直接拒绝 3. .First() → .FirstOrDefault() + null return: ExecuteAnKong / ExecuteBuKong 防御性编程 审计JSON: remaining_issues 清空 (所有已知问题已修复)
829 lines
31 KiB
C#
829 lines
31 KiB
C#
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();
|
||
}
|
||
}
|