From c2b5136085c96ded78119d7198e8fef6906c07c4 Mon Sep 17 00:00:00 2001 From: xiaoou Date: Sat, 4 Jul 2026 20:05:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=5Fwildcards=20static=E2=86=92?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E7=BA=A7=20WildcardRegistry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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→不同变体/测试完全隔离,未来支持随机宝牌 --- RuleEngine.Tests/CoreTests.cs | 14 ++++---- RuleEngine/AI/HumanMahjongPlayer.cs | 5 +-- RuleEngine/AI/RandomMahjongAI.cs | 2 +- RuleEngine/Core/GameState.cs | 5 ++- RuleEngine/Core/MahjongTile.cs | 28 ++++++++-------- RuleEngine/Core/WildcardRegistry.cs | 50 +++++++++++++++++++++++++++++ RuleEngine/MahjongRoom.cs | 21 ++++++------ RuleEngine/Patterns/MeldsSolver.cs | 22 +++++++------ RuleEngine/Phase/PhaseMachine.cs | 7 ++-- 9 files changed, 108 insertions(+), 46 deletions(-) create mode 100644 RuleEngine/Core/WildcardRegistry.cs diff --git a/RuleEngine.Tests/CoreTests.cs b/RuleEngine.Tests/CoreTests.cs index 33c6a4f..fb9f995 100644 --- a/RuleEngine.Tests/CoreTests.cs +++ b/RuleEngine.Tests/CoreTests.cs @@ -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()); - public MeldsSolverTests() - { - // Reset wildcards to avoid contamination from integration tests - MahjongTile.ConfigureWildcards([]); - } - // === 标准胡牌 === [Fact] public void 标准胡_4面子1对_ShouldWin() diff --git a/RuleEngine/AI/HumanMahjongPlayer.cs b/RuleEngine/AI/HumanMahjongPlayer.cs index a776169..7c3069a 100644 --- a/RuleEngine/AI/HumanMahjongPlayer.cs +++ b/RuleEngine/AI/HumanMahjongPlayer.cs @@ -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; diff --git a/RuleEngine/AI/RandomMahjongAI.cs b/RuleEngine/AI/RandomMahjongAI.cs index 717a68c..35292c3 100644 --- a/RuleEngine/AI/RandomMahjongAI.cs +++ b/RuleEngine/AI/RandomMahjongAI.cs @@ -40,7 +40,7 @@ public class RandomMahjongAI if (discards.Count > 0) { var hand = state.Hands.GetValueOrDefault(Name, new List()); - 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 diff --git a/RuleEngine/Core/GameState.cs b/RuleEngine/Core/GameState.cs index f26f8ad..dc6f678 100644 --- a/RuleEngine/Core/GameState.cs +++ b/RuleEngine/Core/GameState.cs @@ -33,6 +33,7 @@ public class MahjongGameState public List 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(FuFlags), IsDeckExhausted = IsDeckExhausted, RecentEvents = new List(RecentEvents), - FlowerReplaced = FlowerReplaced + FlowerReplaced = FlowerReplaced, + LastDrawnTile = LastDrawnTile, + Wildcards = Wildcards.Clone() }; } diff --git a/RuleEngine/Core/MahjongTile.cs b/RuleEngine/Core/MahjongTile.cs index 81059a2..0d18803 100644 --- a/RuleEngine/Core/MahjongTile.cs +++ b/RuleEngine/Core/MahjongTile.cs @@ -8,17 +8,14 @@ public static class MahjongTile // 花: 春=41..菊=48 // 宝牌: 50-59 (extra wildcards added to deck) - private static readonly HashSet _wildcards = new(); + public const int WildcardBase = 50; - /// Configure which normal tiles (e.g. 红中=35) also act as wildcards. - public static void ConfigureWildcards(IEnumerable tiles) - { - _wildcards.Clear(); - foreach (var t in tiles) _wildcards.Add(t); - } + /// Check if tile is in the encoding-50+ wildcard range only (no configured set). + public static bool IsWildcard(int tile) => tile >= WildcardBase && tile <= WildcardBase + 9; - /// Get the set of normal tiles configured as wildcards. - public static IReadOnlySet ConfiguredWildcards => _wildcards; + /// Check if tile is a wildcard including game-specific configured set. + 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) + /// ToString with optional wildcard display context. + public static string ToString(int tile) => ToStringCore(tile); + + /// ToString with game-specific wildcard registry for 🀫 display. + 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; diff --git a/RuleEngine/Core/WildcardRegistry.cs b/RuleEngine/Core/WildcardRegistry.cs new file mode 100644 index 0000000..e71d200 --- /dev/null +++ b/RuleEngine/Core/WildcardRegistry.cs @@ -0,0 +1,50 @@ +namespace RuleEngine.Core; + +/// +/// 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. +/// +public class WildcardRegistry +{ + private readonly HashSet _wildcards = new(); + + /// Base wildcard encoding range (50-59) for extra tiles added to deck. + public const int WildcardBase = 50; + + /// Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards. + public IReadOnlySet ConfiguredWildcards => _wildcards; + + public void Clear() => _wildcards.Clear(); + + public void Add(int tile) => _wildcards.Add(tile); + + public void AddRange(IEnumerable tiles) + { + foreach (var t in tiles) _wildcards.Add(t); + } + + /// Is this tile a wildcard? Checks encoding range AND configured set. + public bool IsWildcard(int tile) => + (tile >= WildcardBase && tile <= WildcardBase + 9) || _wildcards.Contains(tile); + + /// Count how many tiles in a list are wildcards. + public int CountWildcards(IReadOnlyList tiles) => + tiles.Count(t => IsWildcard(t)); + + /// Parse wildcard tile names from DSL into encodings. + public static int? NameToEncoding(string name) => name switch + { + "红中" => 35, + "发财" => 36, + "白板" => 37, + _ => null + }; + + public WildcardRegistry Clone() + { + var r = new WildcardRegistry(); + r.AddRange(_wildcards); + return r; + } +} diff --git a/RuleEngine/MahjongRoom.cs b/RuleEngine/MahjongRoom.cs index 5f28fa3..c6efbcb 100644 --- a/RuleEngine/MahjongRoom.cs +++ b/RuleEngine/MahjongRoom.cs @@ -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); } - /// Count wildcard tiles in a hand (encoding 50-59). - private static int CountWildcardsInHand(List hand) - => hand.Count(MahjongTile.IsWildcard); + /// Count wildcard tiles in a hand (encoding 50-59 + configured). + private int CountWildcardsInHand(List hand) + => State.Wildcards.CountWildcards(hand); /// Combine hand + exposed melds into full virtual hand for CheckWin. private static List 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 diff --git a/RuleEngine/Patterns/MeldsSolver.cs b/RuleEngine/Patterns/MeldsSolver.cs index 39f3acb..2ba9584 100644 --- a/RuleEngine/Patterns/MeldsSolver.cs +++ b/RuleEngine/Patterns/MeldsSolver.cs @@ -15,10 +15,12 @@ public class FanConfig public class MeldsSolver { private readonly Dictionary _fanConfig; + private readonly WildcardRegistry _wildcards; - public MeldsSolver(Dictionary fanConfig) + public MeldsSolver(Dictionary 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(); 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(); 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 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(), PairTiles = new List(), - WildcardsUsed = wildcardCount + tiles.Count(MahjongTile.IsWildcard) - wildcardsAvailable + WildcardsUsed = wildcardCount + tiles.Count(_wildcards.IsWildcard) - wildcardsAvailable }; } diff --git a/RuleEngine/Phase/PhaseMachine.cs b/RuleEngine/Phase/PhaseMachine.cs index 4d582fa..6c60f74 100644 --- a/RuleEngine/Phase/PhaseMachine.cs +++ b/RuleEngine/Phase/PhaseMachine.cs @@ -42,11 +42,13 @@ public class MahjongPhaseMachine private readonly bool _require258; private readonly Dictionary _fanConfig; private readonly bool _jiHuSelfDrawOnly; + private readonly WildcardRegistry _wildcards; public MahjongPhaseMachine(List phases, MeldsSolver solver, int wildcardCount = 0, bool require258 = false, Dictionary? 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)