[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个通过
This commit is contained in:
xiaoou
2026-07-03 17:51:59 +08:00
commit 3c6748bf06
24 changed files with 7083 additions and 0 deletions

View File

@ -0,0 +1,600 @@
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();
}
}