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→不同变体/测试完全隔离,未来支持随机宝牌
This commit is contained in:
xiaoou
2026-07-04 20:05:26 +08:00
parent 0dbb7c2d15
commit c2b5136085
9 changed files with 108 additions and 46 deletions

View File

@ -39,6 +39,14 @@ public class MahjongTileTests
public void IsWildcard_50_True() => Assert.True(MahjongTile.IsWildcard(50));
[Fact]
public void IsWildcard_普通牌_False() => Assert.False(MahjongTile.IsWildcard(1));
[Fact]
public void ToString_配置wildcard_显示癞子标记()
{
var wc = new WildcardRegistry();
wc.Add(35);
Assert.Equal("🀫中", MahjongTile.ToString(35, wc));
}
}
public class DeckTests
@ -84,12 +92,6 @@ public class MeldsSolverTests
{
private readonly MeldsSolver _solver = new(new Dictionary<string, FanConfig>());
public MeldsSolverTests()
{
// Reset wildcards to avoid contamination from integration tests
MahjongTile.ConfigureWildcards([]);
}
// === 标准胡牌 ===
[Fact]
public void _4面子1对_ShouldWin()

View File

@ -34,7 +34,7 @@ public class HumanMahjongPlayer
Console.WriteLine($" 牌墙: {state.Deck.Count}张 | 弃牌堆: {state.DiscardPool.Count}张");
// Show wildcard info
var wcTiles = MahjongTile.ConfiguredWildcards;
var wcTiles = state.Wildcards.ConfiguredWildcards;
if (wcTiles.Count > 0)
Console.WriteLine($" 🀫 宝牌(癞子): {string.Join(" ", wcTiles.Select(MahjongTile.ToString))}");
@ -118,10 +118,11 @@ public class HumanMahjongPlayer
// Check all possible tiles for win potential
var allTiles = MahjongTile.AllTiles(false, false);
int wc = state.Wildcards.CountWildcards(fullHand);
foreach (var tile in allTiles)
{
if (hand.Count(t => t == tile) >= 4) continue;
var r = _solver.CheckWin(fullHand, newTile: tile);
var r = _solver.CheckWin(fullHand, newTile: tile, wildcardCount: wc);
if (r != null && r.IsWin) return true;
}
return false;

View File

@ -40,7 +40,7 @@ public class RandomMahjongAI
if (discards.Count > 0)
{
var hand = state.Hands.GetValueOrDefault(Name, new List<int>());
var nonWildTiles = hand.Where(t => !MahjongTile.IsWildcard(t)).ToList();
var nonWildTiles = hand.Where(t => !state.Wildcards.IsWildcard(t)).ToList();
if (nonWildTiles.Count > 0)
return ("discard", nonWildTiles[_rng.Next(nonWildTiles.Count)]);
// Only wildcards left — discard one

View File

@ -33,6 +33,7 @@ public class MahjongGameState
public List<GameEvent> RecentEvents { get; set; } = new();
public int FlowerReplaced { get; set; }
public int? LastDrawnTile { get; set; }
public WildcardRegistry Wildcards { get; set; } = new();
public MahjongGameState Clone()
{
@ -56,7 +57,9 @@ public class MahjongGameState
FuFlags = new Dictionary<string, bool>(FuFlags),
IsDeckExhausted = IsDeckExhausted,
RecentEvents = new List<GameEvent>(RecentEvents),
FlowerReplaced = FlowerReplaced
FlowerReplaced = FlowerReplaced,
LastDrawnTile = LastDrawnTile,
Wildcards = Wildcards.Clone()
};
}

View File

@ -8,17 +8,14 @@ public static class MahjongTile
// 花: 春=41..菊=48
// 宝牌: 50-59 (extra wildcards added to deck)
private static readonly HashSet<int> _wildcards = new();
public const int WildcardBase = 50;
/// <summary>Configure which normal tiles (e.g. 红中=35) also act as wildcards.</summary>
public static void ConfigureWildcards(IEnumerable<int> tiles)
{
_wildcards.Clear();
foreach (var t in tiles) _wildcards.Add(t);
}
/// <summary>Check if tile is in the encoding-50+ wildcard range only (no configured set).</summary>
public static bool IsWildcard(int tile) => tile >= WildcardBase && tile <= WildcardBase + 9;
/// <summary>Get the set of normal tiles configured as wildcards.</summary>
public static IReadOnlySet<int> ConfiguredWildcards => _wildcards;
/// <summary>Check if tile is a wildcard including game-specific configured set.</summary>
public static bool IsWildcard(int tile, WildcardRegistry wildcards) =>
tile >= WildcardBase && tile <= WildcardBase + 9 || wildcards.IsWildcard(tile);
public static int Encode(string suit, int rank)
{
@ -31,12 +28,18 @@ public static class MahjongTile
};
}
public static string ToString(int tile)
/// <summary>ToString with optional wildcard display context.</summary>
public static string ToString(int tile) => ToStringCore(tile);
/// <summary>ToString with game-specific wildcard registry for 🀫 display.</summary>
public static string ToString(int tile, WildcardRegistry wildcards) =>
wildcards.IsWildcard(tile) ? $"🀫{TileName(tile)}" : ToStringCore(tile);
private static string ToStringCore(int tile)
{
if (tile >= 1 && tile <= 9) return $"{tile}万";
if (tile >= 11 && tile <= 19) return $"{tile - 10}条";
if (tile >= 21 && tile <= 29) return $"{tile - 20}筒";
if (_wildcards.Contains(tile)) return $"🀫{TileName(tile)}";
return tile switch
{
31 => "东", 32 => "南", 33 => "西", 34 => "北",
@ -84,7 +87,6 @@ public static class MahjongTile
public static bool IsHonor(int tile) => tile >= 31 && tile <= 37;
public static bool IsFlower(int tile) => tile >= 41 && tile <= 48;
public static bool IsWildcard(int tile) => tile >= 50 && tile <= 59 || _wildcards.Contains(tile);
public static bool IsNumbered(int tile) => tile >= 1 && tile <= 29;
public static bool IsTerminal(int tile)
{
@ -99,8 +101,6 @@ public static class MahjongTile
public static bool SameSuit(int a, int b) => Suit(a) == Suit(b);
public const int WildcardBase = 50;
public static int[] AllTiles(bool includeHonors = false, bool includeFlowers = false, int wildcardCount = 0)
{
int count = 27 + (includeHonors ? 7 : 0) + (includeFlowers ? 8 : 0) + wildcardCount;

View File

@ -0,0 +1,50 @@
namespace RuleEngine.Core;
/// <summary>
/// Per-game wildcard configuration — replaces the old static HashSet approach.
/// Each MahjongGameState owns its own registry so different game variants
/// don't leak wildcard state between tests or concurrent games.
/// </summary>
public class WildcardRegistry
{
private readonly HashSet<int> _wildcards = new();
/// <summary>Base wildcard encoding range (50-59) for extra tiles added to deck.</summary>
public const int WildcardBase = 50;
/// <summary>Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards.</summary>
public IReadOnlySet<int> ConfiguredWildcards => _wildcards;
public void Clear() => _wildcards.Clear();
public void Add(int tile) => _wildcards.Add(tile);
public void AddRange(IEnumerable<int> tiles)
{
foreach (var t in tiles) _wildcards.Add(t);
}
/// <summary>Is this tile a wildcard? Checks encoding range AND configured set.</summary>
public bool IsWildcard(int tile) =>
(tile >= WildcardBase && tile <= WildcardBase + 9) || _wildcards.Contains(tile);
/// <summary>Count how many tiles in a list are wildcards.</summary>
public int CountWildcards(IReadOnlyList<int> tiles) =>
tiles.Count(t => IsWildcard(t));
/// <summary>Parse wildcard tile names from DSL into encodings.</summary>
public static int? NameToEncoding(string name) => name switch
{
"红中" => 35,
"发财" => 36,
"白板" => 37,
_ => null
};
public WildcardRegistry Clone()
{
var r = new WildcardRegistry();
r.AddRange(_wildcards);
return r;
}
}

View File

@ -28,7 +28,7 @@ public class MahjongRoom
_ais = playerNames.Select((n, i) => new RandomMahjongAI(n, new Random(i * 7919))).ToList();
var fanConfig = BuildFanConfig();
_solver = new MeldsSolver(fanConfig);
_solver = new MeldsSolver(fanConfig, State.Wildcards);
if (humanPlayer != null)
_human = new HumanMahjongPlayer(humanPlayer, _solver);
@ -37,7 +37,8 @@ public class MahjongRoom
var (wc, r258) = GetWinParams();
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258,
fanConfig: fanConfig,
jiHuSelfDrawOnly: _rules.WinRule?.JiHuSelfDrawOnly ?? false);
jiHuSelfDrawOnly: _rules.WinRule?.JiHuSelfDrawOnly ?? false,
wildcards: State.Wildcards);
_scoreEngine = new MahjongScoreEngine(new ScoringConfig
{
@ -269,9 +270,9 @@ public class MahjongRoom
return (wc, r258);
}
/// <summary>Count wildcard tiles in a hand (encoding 50-59).</summary>
private static int CountWildcardsInHand(List<int> hand)
=> hand.Count(MahjongTile.IsWildcard);
/// <summary>Count wildcard tiles in a hand (encoding 50-59 + configured).</summary>
private int CountWildcardsInHand(List<int> hand)
=> State.Wildcards.CountWildcards(hand);
/// <summary>Combine hand + exposed melds into full virtual hand for CheckWin.</summary>
private static List<int> FullHand(MahjongGameState state, string player, int? extraTile = null)
@ -524,7 +525,7 @@ public class MahjongRoom
if (!_rules.Phases.Any(ph => ph.ParallelElimination))
{
_scoreEngine.Settle(State, player, winResult, isSelfDraw: false);
IsFinished = true;
Settle();
}
return true;
}
@ -533,7 +534,7 @@ public class MahjongRoom
{
var hand = State.Hands[player];
var group = hand.GroupBy(t => t)
.First(g => g.Count() >= 4 && !MahjongTile.IsWildcard(g.Key));
.First(g => g.Count() >= 4 && !State.Wildcards.IsWildcard(g.Key));
int tile = group.Key;
for (int i = 0; i < 4; i++) State.Hands[player].Remove(tile);
State.Exposed[player].Add(new Meld
@ -674,7 +675,7 @@ public class MahjongRoom
bool includeHonors = _rules.Deck.IncludeHonors;
// Reset wildcards — always clear to avoid test state leak
MahjongTile.ConfigureWildcards([]);
State.Wildcards.Clear();
int wildcardCount = 0;
if (_rules.WildcardRules != null)
{
@ -682,9 +683,9 @@ public class MahjongRoom
{
// Fixed wildcards: specified tiles ARE the wildcards, not extra tiles
var wcTiles = _rules.WildcardRules.Tiles
.Select(t => t switch { "红中" => 35, "发财" => 36, "白板" => 37, _ => -1 })
.Select(t => WildcardRegistry.NameToEncoding(t) ?? -1)
.Where(t => t > 0).ToArray();
MahjongTile.ConfigureWildcards(wcTiles);
State.Wildcards.AddRange(wcTiles);
wildcardCount = 0; // No extra tiles
}
else

View File

@ -15,10 +15,12 @@ public class FanConfig
public class MeldsSolver
{
private readonly Dictionary<string, FanConfig> _fanConfig;
private readonly WildcardRegistry _wildcards;
public MeldsSolver(Dictionary<string, FanConfig> fanConfig)
public MeldsSolver(Dictionary<string, FanConfig> fanConfig, WildcardRegistry? wildcards = null)
{
_fanConfig = fanConfig;
_wildcards = wildcards ?? new();
}
// === 主入口 ===
@ -32,7 +34,7 @@ public class MeldsSolver
// 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(MahjongTile.IsWildcard);
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 };
@ -97,7 +99,7 @@ public class MeldsSolver
var counts = new int[34];
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t) || MahjongTile.IsFlower(t)) continue;
if (_wildcards.IsWildcard(t) || MahjongTile.IsFlower(t)) continue;
int idx = TileToIndex(t);
if (idx >= 0 && idx < 34)
counts[idx]++;
@ -330,7 +332,7 @@ public class MeldsSolver
var counts = new Dictionary<int, int>();
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (_wildcards.IsWildcard(t)) continue;
if (MahjongTile.IsFlower(t)) return null; // 七对不能有花牌
counts[t] = counts.GetValueOrDefault(t) + 1;
}
@ -372,7 +374,7 @@ public class MeldsSolver
int extra = 0; // duplicate of required tiles
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (_wildcards.IsWildcard(t)) continue;
if (MahjongTile.IsFlower(t)) return null;
if (required.Contains(t))
{
@ -438,7 +440,7 @@ public class MeldsSolver
// All numbered tiles and honors are allowed, flowers are rejected.
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (_wildcards.IsWildcard(t)) continue;
if (MahjongTile.IsFlower(t)) return null;
if (!MahjongTile.IsNumbered(t) && !MahjongTile.IsHonor(t)) return null;
}
@ -448,7 +450,7 @@ public class MeldsSolver
var honors = new List<int>();
foreach (var t in tiles)
{
if (MahjongTile.IsWildcard(t)) continue;
if (_wildcards.IsWildcard(t)) continue;
if (MahjongTile.IsHonor(t)) honors.Add(t);
else
{
@ -494,7 +496,7 @@ public class MeldsSolver
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 => !MahjongTile.IsWildcard(t) && !MahjongTile.IsFlower(t)).ToList();
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)
@ -513,7 +515,7 @@ public class MeldsSolver
rankCounts[r]++;
}
int wildcardsAvailable = wildcardCount + tiles.Count(MahjongTile.IsWildcard);
int wildcardsAvailable = wildcardCount + tiles.Count(_wildcards.IsWildcard);
for (int r = 1; r <= 9; r++)
{
if (rankCounts[r] < 2)
@ -529,7 +531,7 @@ public class MeldsSolver
IsWin = true,
Melds = new List<Meld>(),
PairTiles = new List<int>(),
WildcardsUsed = wildcardCount + tiles.Count(MahjongTile.IsWildcard) - wildcardsAvailable
WildcardsUsed = wildcardCount + tiles.Count(_wildcards.IsWildcard) - wildcardsAvailable
};
}

View File

@ -42,11 +42,13 @@ public class MahjongPhaseMachine
private readonly bool _require258;
private readonly Dictionary<string, FanConfig> _fanConfig;
private readonly bool _jiHuSelfDrawOnly;
private readonly WildcardRegistry _wildcards;
public MahjongPhaseMachine(List<PhaseConfig> phases, MeldsSolver solver,
int wildcardCount = 0, bool require258 = false,
Dictionary<string, FanConfig>? fanConfig = null,
bool jiHuSelfDrawOnly = false)
bool jiHuSelfDrawOnly = false,
WildcardRegistry? wildcards = null)
{
_phases = phases;
_solver = solver;
@ -54,6 +56,7 @@ public class MahjongPhaseMachine
_require258 = require258;
_fanConfig = fanConfig ?? new();
_jiHuSelfDrawOnly = jiHuSelfDrawOnly;
_wildcards = wildcards ?? new();
}
public PhaseConfig? GetPhase(string name) => _phases.FirstOrDefault(p => p.Name == name);
@ -141,7 +144,7 @@ public class MahjongPhaseMachine
{
// Check if player has 4 identical tiles in hand (暗杠)
var hand = state.Hands[playerId];
return hand.GroupBy(t => t).Any(g => g.Count() >= 4 && !MahjongTile.IsWildcard(g.Key));
return hand.GroupBy(t => t).Any(g => g.Count() >= 4 && !_wildcards.IsWildcard(g.Key));
}
private bool CanBuKong(MahjongGameState state, string playerId)