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:
@ -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()
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
50
RuleEngine/Core/WildcardRegistry.cs
Normal file
50
RuleEngine/Core/WildcardRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user