Files
card-game-engine/RuleEngine/Patterns/MeldsSolver.cs
xiaoou c2b5136085 refactor: _wildcards static→实例级 WildcardRegistry
Root cause: static HashSet跨测试/跨变体污染+硬编码名→编码映射

Changes:
- WildcardRegistry: 实例级wildcard管理(IsWildcard/CountWildcards/Clone)
- GameState: 新增Wildcards属性(含Clone)
- MahjongTile: 移除static_wildcards,IsWildcard仅查编码50-59
  +ToString(tile,wildcards)重载用于游戏上下文显示
- MeldsSolver: _wildcards字段→构造函数注入(WildcardRegistry?)
- PhaseMachine: _wildcards字段+构造注入
- Deal(): State.Wildcards.Clear()+AddRange替代static ConfigureWildcards
  +NameToEncoding查表替代switch硬编码
- RandomMahjongAI/HumanMahjongPlayer: state.Wildcards替代static调用
- HumanMahjongPlayer.CheckTing: 补wildcardCount参数(之前遗漏)
- ExecuteWinClaim: IsFinished=true→Settle()(之前Phase漏设)

Test: CoreTests清理ConfigureWildcards()→WildcardRegistry测试
Impact: 零static state→不同变体/测试完全隔离,未来支持随机宝牌
2026-07-04 20:05:26 +08:00

707 lines
25 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)
{
sevenPairs.Fans = ApplyFanExclusions(IdentifyFans(sevenPairs));
return sevenPairs;
}
// 2. 十三幺
var thirteen = TryThirteenOrphans(tiles, wildcardCount);
if (thirteen != null)
{
thirteen.Fans = ApplyFanExclusions(IdentifyFans(thirteen));
return thirteen;
}
// 3. 全不靠
var allOrphans = TryAllOrphans(tiles, wildcardCount);
if (allOrphans != null)
{
allOrphans.Fans = ApplyFanExclusions(IdentifyFans(allOrphans));
return allOrphans;
}
// 4. 一色双龙会
var doubleDragon = TryDoubleDragon(tiles, wildcardCount);
if (doubleDragon != null)
{
doubleDragon.Fans = ApplyFanExclusions(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 = 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;
}
// === 十三幺 ===
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)
{
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);
}
// === 番型识别 (DSL-driven: structural patterns auto-detected) ===
public List<string> IdentifyFans(MeldsResult result)
{
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 (always evaluated regardless of config) ===
// 清一色: 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 && allTiles.Any(MahjongTile.IsHonor))
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("对对胡");
// 暗七对 (all melds are pairs — from seven-pairs path)
if (melds.Count > 0 && melds.All(m => m.Type == "pair"))
fans.Add("暗七对");
// 带幺九: all melds contain 1/9/honor
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("带幺九");
}
// 混幺九: all tiles are terminals or honors, includes at least one honor and one numbered
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, non-258 pair, non-single-wait)
if (melds.Count > 0 && melds.All(m => m.Type == "shunzi"))
fans.Add("平胡");
// 全带幺 (every meld and pair contains a terminal or honor) — same as 带幺九
// Already covered above as 带幺九
// 断幺九: no terminals or honors
bool noTerminals = allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t));
if (noTerminals && allTiles.Count > 0)
fans.Add("断幺九");
// 全大 (all ranks 7-9)
bool allBig = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7);
if (allBig && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全大");
// 全中 (all ranks 4-6)
bool allMid = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6);
if (allMid && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全中");
// 全小 (all ranks 1-3)
bool allSmall = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3);
if (allSmall && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全小");
// 大于五 (all ranks > 5)
bool allGreater5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5);
if (allGreater5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("大于五");
// 小于五 (all ranks < 5)
bool allLess5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5);
if (allLess5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("小于五");
// 全双 (all ranks even)
bool allEven = allTiles.All(t =>
!MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0);
if (allEven && allTiles.All(t => MahjongTile.IsNumbered(t) || MahjongTile.IsHonor(t)))
fans.Add("全双");
// 碰碰和 (same as 对对胡, guobiao name)
if (melds.Count > 0 && melds.All(m => m.Type == "kezi") && !fans.Contains("对对胡"))
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 "全双").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();
}
}