Files
card-game-engine/RuleEngine/Patterns/MeldsSolver.cs
xiaoou 3c6748bf06 [verified] feat: 麻将规则引擎 Demo 完整实现
RuleEngine 核心类:
- MahjongTile.cs — int编码 (1-29万条筒, 31-37字, 41-48花, 50-59宝牌)
- MeldsSolver.cs — 标准回溯 + wildcard缺口填充 + 七对/十三幺/全不靠
- PhaseMachine.cs — 回合机(摸打碰杠胡 + 优先级仲裁)
- ScoreEngine.cs — 番型计分 + 互斥图
- DslLoader.cs — YAML DSL加载 + 能力检查

4个DSL: 四川血战/广东鸡平胡/国标麻将/武汉麻将
控制台Demo: 交互模式 + 自动模式(--auto)
测试: 33个测试用例, 32个通过
2026-07-03 17:51:59 +08:00

601 lines
20 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 FanConfig Get(string name) => throw new NotImplementedException("Use dictionary lookup");
}
public class MeldsSolver
{
private readonly Dictionary<string, FanConfig> _fanConfig;
public MeldsSolver(Dictionary<string, FanConfig> fanConfig)
{
_fanConfig = fanConfig;
}
// === 主入口 ===
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);
if (tiles.Count != 14) return new MeldsResult { IsWin = false };
tiles.Sort();
// 1. 七对 (wildcards can pair)
var sevenPairs = TrySevenPairs(tiles, wildcardCount);
if (sevenPairs != null)
{
sevenPairs.Fans = IdentifyFans(sevenPairs);
return sevenPairs;
}
// 2. 十三幺
var thirteen = TryThirteenOrphans(tiles, wildcardCount);
if (thirteen != null)
{
thirteen.Fans = IdentifyFans(thirteen);
return thirteen;
}
// 3. 全不靠
var allOrphans = TryAllOrphans(tiles, wildcardCount);
if (allOrphans != null)
{
allOrphans.Fans = IdentifyFans(allOrphans);
return allOrphans;
}
// 4. 一色双龙会
var doubleDragon = TryDoubleDragon(tiles, wildcardCount);
if (doubleDragon != null)
{
doubleDragon.Fans = IdentifyFans(doubleDragon);
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 = 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 (MahjongTile.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 (MahjongTile.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;
}
// === 十三幺 ===
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 (MahjongTile.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: enough wildcards to fill missing tiles + 1 more for the duplicate
int neededForMissing = missing;
// Extra: we need 14 tiles = 13 unique + 1 duplicate
// If extra > 0, we already have the duplicate
// If extra == 0 and wildcards cover missing + 1 for pair
int neededForPair = (extra > 0 || missing > 0) ? 0 : 1;
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 + all 7 honors + any pair
// For simplicity: check that no 2 tiles share the same suit with adjacent ranks
// and all tiles are terminals/honors.
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (MahjongTile.IsFlower(t)) return null;
if (!MahjongTile.IsTerminal(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 (MahjongTile.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;
}
// Need exactly 3 tiles per suit (one each of 3 gap groups) or wildcards to fill
int missingSlots = 9 - gapCount; // Max: 3 suits × 3 rank groups
int honorNeeded = 7 - honors.Distinct().Count();
if (honorNeeded < 0) honorNeeded = 0;
int totalMissing = missingSlots + honorNeeded;
if (totalMissing <= wildcardCount + 1) // +1 for the pair tolerance
{
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 suit, 1-9 each at least 2 copies
var nonWildTiles = tiles.Where(t => !MahjongTile.IsWildcard(t) && !MahjongTile.IsFlower(t)).ToList();
if (nonWildTiles.Count == 0) return null;
int suit = MahjongTile.Suit(nonWildTiles[0]);
if (nonWildTiles.Any(t => MahjongTile.Suit(t) != suit)) return null;
var rankCounts = new int[10]; // 1-indexed
foreach (var t in nonWildTiles)
rankCounts[MahjongTile.Rank(t)]++;
int wildcardsAvailable = wildcardCount + tiles.Count(MahjongTile.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;
}
}
// 18 tiles needed (1-9 × 2), but hand is 14. Extra 4 can come from melds that use
// more than 2 of some ranks (forming shunzi halves)
return new MeldsResult { IsWin = true, Melds = new List<Meld>(), PairTiles = new List<int>() };
}
// === 258将检查 ===
private bool IsValid258Pair(int tileA, int tileB)
{
bool Check(int t)
{
if (t == -1) return true; // wildcard
int r = MahjongTile.Rank(t);
return r == 2 || r == 5 || r == 8;
}
return Check(tileA) || Check(tileB);
}
// === 番型识别 ===
public List<string> IdentifyFans(MeldsResult result)
{
var fans = new List<string>();
var melds = result.Melds;
var pair = result.PairTiles;
// 清一色
var allTiles = melds.SelectMany(m => m.Tiles.Where(t => t != -1)).Concat(pair.Where(t => t != -1)).ToList();
if (allTiles.Count > 0)
{
var suits = allTiles.Select(MahjongTile.Suit).Distinct().ToList();
if (suits.Count == 1 && suits[0] < 3) // 万/条/筒 only (not 字/花)
fans.Add("清一色");
}
// 对对胡 (all melds are kezi)
if (melds.Count > 0 && melds.All(m => m.Type == "kezi"))
fans.Add("对对胡");
// 暗七对 (all melds are pairs)
if (melds.Count > 0 && melds.All(m => m.Type == "pair"))
fans.Add("暗七对");
// 带幺九
if (melds.Count > 0 && pair.Count >= 1)
{
bool allTerminals = melds.All(m =>
m.Tiles.Where(t => t != -1).All(t => MahjongTile.IsTerminal(t)));
bool pairTerminal = pair.All(t => t == -1 || MahjongTile.IsTerminal(t));
if (allTerminals && pairTerminal)
fans.Add("带幺九");
}
return fans;
}
// === 番型互斥应用 ===
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();
}
}