From 4302ec2fd53a38e898cd04d56c83d56bd65b8ec0 Mon Sep 17 00:00:00 2001 From: xiaoou Date: Sat, 4 Jul 2026 11:45:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=20?= =?UTF-8?q?+=20=E6=89=8B=E5=8A=A8=E5=87=BA=E7=89=8C=E6=A8=A1=E5=BC=8F=20+?= =?UTF-8?q?=20discart=20pool=E5=B9=BD=E7=81=B5=E7=89=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 集成测试 (10个): - 发牌: 庄14闲13, 牌墙55 - 回合流转: 庄家第一轮不摸牌, 非庄家摸牌后出牌 - 数据完整性: 10回合后手牌+副露+牌墙+弃牌=108 - 对局完整性: 4种DSL全部不出错完成 - 构造听牌: 完整手牌第一轮自摸 手动出牌 (--human): - HumanMahjongPlayer: 显示手牌/可选操作/h=胡/p=碰/k=杠/1-N=出牌 - MahjongRoom: humanPlayer参数 修复: DiscardPool碰/胡后不移除→幽灵牌→总牌数溢出 --- Demo/Program.cs | 7 +- .../MahjongRoomIntegrationTests.cs | 198 ++++++++++++++++++ RuleEngine/AI/HumanMahjongPlayer.cs | 68 ++++++ RuleEngine/MahjongRoom.cs | 16 +- 4 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 RuleEngine.Tests/MahjongRoomIntegrationTests.cs create mode 100644 RuleEngine/AI/HumanMahjongPlayer.cs diff --git a/Demo/Program.cs b/Demo/Program.cs index 29c3b17..af3978b 100644 --- a/Demo/Program.cs +++ b/Demo/Program.cs @@ -4,6 +4,7 @@ using RuleEngine.Dsl; // Parse arguments bool autoMode = args.Contains("--auto"); +bool humanMode = args.Contains("--human"); string dslPath = "dsl-examples/xuezhandaodi.yaml"; int dslIdx = Array.IndexOf(args, "--dsl"); if (dslIdx >= 0 && dslIdx + 1 < args.Length) @@ -54,7 +55,8 @@ if (autoMode) { try { - var room = new MahjongRoom(rules, new[] { "AI-东", "AI-南", "AI-西", "AI-北" }, autoMode: true); + var room = new MahjongRoom(rules, new[] { "AI-东", "AI-南", "AI-西", "AI-北" }, autoMode: true, + humanPlayer: humanMode ? "AI-东" : null); room.Run(); if (room.State.HuPlayers.Count > 0) wins++; @@ -80,7 +82,8 @@ else Console.WriteLine($"=== 麻将规则引擎 Demo — {rules.Game.Name} === (交互模式,按 Enter 继续下一步)"); Console.WriteLine(); - var room = new MahjongRoom(rules, new[] { "AI-东", "AI-南", "AI-西", "AI-北" }, autoMode: false); + var room = new MahjongRoom(rules, new[] { "AI-东", "AI-南", "AI-西", "AI-北" }, autoMode: false, + humanPlayer: humanMode ? "AI-东" : null); room.Deal(); // Show dealt hands diff --git a/RuleEngine.Tests/MahjongRoomIntegrationTests.cs b/RuleEngine.Tests/MahjongRoomIntegrationTests.cs new file mode 100644 index 0000000..a2855b9 --- /dev/null +++ b/RuleEngine.Tests/MahjongRoomIntegrationTests.cs @@ -0,0 +1,198 @@ +using RuleEngine; +using RuleEngine.Core; +using RuleEngine.Dsl; + +namespace RuleEngine.Tests; + +/// +/// Integration tests for MahjongRoom — game flow, deal, win detection, reactions. +/// These catch bugs that MeldsSolver unit tests can't (wildcard params, dealer draw, etc.) +/// +public class MahjongRoomIntegrationTests +{ + private static MahjongDslRoot LoadDsl(string name) + { + var caps = new CapabilityRegistry(); + caps.Register("meldsolver.standard_win"); + caps.Register("meldsolver.seven_pairs"); + caps.Register("meldsolver.thirteen_orphans"); + caps.Register("meldsolver.all_orphans"); + caps.Register("meldsolver.double_dragon"); + caps.Register("meldsolver.wildcard"); + caps.Register("deck.flower_cards"); + caps.Register("phase.mahjong_turn"); + caps.Register("phase.parallel_elimination"); + caps.Register("phase.priority_arbitration"); + caps.Register("scoring.fan_exclusion"); + caps.Register("scoring.pre_hooks"); + caps.Register("deck.generator_mahjong"); + var loader = new DslLoader(caps); + return loader.Load(Path.Combine("..", "..", "..", "..", "dsl-examples", $"{name}.yaml")); + } + + // === Deal === + [Fact] + public void 发牌_四川_庄14闲13_牌墙55() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + Assert.Equal(14, room.State.Hands["东"].Count); + Assert.Equal(13, room.State.Hands["南"].Count); + Assert.Equal(13, room.State.Hands["西"].Count); + Assert.Equal(13, room.State.Hands["北"].Count); + Assert.Equal(55, room.State.Deck.Count); // 108 - 53 = 55 + Assert.Equal("东", room.State.Dealer); + Assert.Equal("东", room.State.CurrentPlayer); + } + + [Fact] + public void 发牌_武汉_牌库正确() + { + var rules = LoadDsl("wuhan"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + int totalTiles = room.State.Hands.Sum(h => h.Value.Count) + room.State.Deck.Count; + // NOTE: UnderscoredNamingConvention doesn't map includeHonors/WildcardCount + // So deck uses defaults (108 numbered). Known issue — fix when switching naming policy. + Assert.Equal(108, totalTiles); + } + + // === Turn flow === + [Fact] + public void 庄家第一轮不摸牌_直接出牌() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + Assert.Equal(14, room.State.Hands["东"].Count); + Assert.Equal(55, room.State.Deck.Count); + + // First turn: dealer should NOT draw (already has 14) + room.StepTurn(); + + // Dealer should have discarded one tile + Assert.Equal(13, room.State.Hands["东"].Count); + Assert.Equal(55, room.State.Deck.Count); // No draw → deck unchanged + Assert.NotNull(room.State.LastDiscard); + } + + [Fact] + public void 非庄家第一轮_摸牌后出牌() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + // Skip dealer's first turn + var evt1 = room.StepTurn(); + Assert.Contains(evt1, e => e.Type == "discard"); + + // Second turn: next player draws then discards + Assert.Equal("南", room.State.CurrentPlayer); + Assert.Equal(13, room.State.Hands["南"].Count); + Assert.Equal(55, room.State.Deck.Count); + + var evt2 = room.StepTurn(); + + Assert.Equal(13, room.State.Hands["南"].Count); // draw +1, discard -1 = net 0 + Assert.Equal(54, room.State.Deck.Count); // one card drawn + Assert.Contains(evt2, e => e.Type == "draw"); + Assert.Contains(evt2, e => e.Type == "discard"); + } + + // === Pung/Reaction === + [Fact] + public void 回合之后_手牌总数保持恒定() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + int totalBefore = room.State.Hands.Sum(h => h.Value.Count) + + room.State.Exposed.Sum(e => e.Value.Sum(m => m.Tiles.Count)); + + // Play 10 turns + for (int i = 0; i < 10 && !room.IsFinished; i++) + room.StepTurn(); + + int totalAfter = room.State.Hands.Sum(h => h.Value.Count) + + room.State.Exposed.Sum(e => e.Value.Sum(m => m.Tiles.Count)) + + room.State.Deck.Count + + room.State.DiscardPool.Count; + + // Total tiles in system should be constant (108 for Sichuan) + Assert.Equal(108, totalAfter); + } + + // === Full game === + [Fact] + public void 完整一局_四川血战_不出错() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Run(); + + Assert.True(room.IsFinished); + Assert.Equal("settle", room.State.Phase); + // Deck should be depleted + Assert.Empty(room.State.Deck); + // Scores should sum to zero (zero-sum game) + Assert.Equal(0, room.State.Scores.Values.Sum()); + } + + [Fact] + public void 完整一局_武汉_不出错() + { + var rules = LoadDsl("wuhan"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Run(); + + Assert.True(room.IsFinished); + Assert.Equal("settle", room.State.Phase); + Assert.Equal(0, room.State.Scores.Values.Sum()); + } + + [Fact] + public void 完整一局_国标_不出错() + { + var rules = LoadDsl("guobiao"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Run(); + + Assert.True(room.IsFinished); + Assert.Equal("settle", room.State.Phase); + } + + [Fact] + public void 完整一局_广东鸡平胡_不出错() + { + var rules = LoadDsl("guangdong_jipinghu"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Run(); + + Assert.True(room.IsFinished); + Assert.Equal("settle", room.State.Phase); + } + + // === Constructed win scenario === + [Fact] + public void 构造听牌_庄家第一轮自摸() + { + var rules = LoadDsl("xuezhandaodi"); + var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" }); + room.Deal(); + + // 东 has a complete win: 123万 456万 789万 111条 99条 + room.State.Hands["东"] = new List { 1,2,3,4,5,6,7,8,9,11,11,11,19,19 }; + room.State.Deck = new List(); + + room.StepTurn(); // dealer first turn, no draw, checks win + + Assert.Contains("东", room.State.HuPlayers); + Assert.True(room.State.AlivePlayers.Count < 4); + } +} diff --git a/RuleEngine/AI/HumanMahjongPlayer.cs b/RuleEngine/AI/HumanMahjongPlayer.cs new file mode 100644 index 0000000..b3b8438 --- /dev/null +++ b/RuleEngine/AI/HumanMahjongPlayer.cs @@ -0,0 +1,68 @@ +namespace RuleEngine.AI; + +using RuleEngine.Core; +using RuleEngine.Phase; + +/// +/// Human player — reads commands from console stdin. +/// Commands: h=hu, p=pung, k=kong, c=chi, 1-9=discard tile by index, q=quit +/// +public class HumanMahjongPlayer +{ + public string Name { get; } + + public HumanMahjongPlayer(string name) + { + Name = name; + } + + public (string action, int? tile) Decide(List legalActions, MahjongGameState state) + { + var hand = state.Hands.GetValueOrDefault(Name, new List()); + var sorted = hand.OrderBy(t => t).ToList(); + + while (true) + { + Console.WriteLine(); + Console.WriteLine($"=== {Name} 的回合 ==="); + Console.WriteLine($"手牌 ({hand.Count}张):"); + for (int i = 0; i < sorted.Count; i++) + Console.WriteLine($" [{i + 1}] {MahjongTile.ToString(sorted[i])}"); + Console.WriteLine(); + + // Show available actions + var winOpt = legalActions.FirstOrDefault(a => a.Action == "win"); + var pungOpt = legalActions.FirstOrDefault(a => a.Action == "pung"); + var kongOpt = legalActions.FirstOrDefault(a => a.Action == "ming_kong" || a.Action == "an_kong"); + var chiOpt = legalActions.FirstOrDefault(a => a.Action == "chi"); + + Console.Write("可选操作: "); + var ops = new List(); + if (hand.Count > 0) ops.Add("1-{0}(出牌)".Replace("{0}", hand.Count.ToString())); + if (pungOpt != null) ops.Add("p(碰)"); + if (kongOpt != null) ops.Add("k(杠)"); + if (chiOpt != null) ops.Add("c(吃)"); + if (winOpt != null) ops.Add("h(胡)"); + ops.Add("q(退出)"); + Console.WriteLine(string.Join(" ", ops)); + Console.Write("> "); + + string? input = Console.ReadLine()?.Trim().ToLower(); + if (string.IsNullOrEmpty(input)) continue; + + if (input == "q") return ("pass", null); + if (input == "h" && winOpt != null) return ("win", null); + if (input == "p" && pungOpt != null) return ("pung", null); + if (input == "k" && kongOpt != null) return (kongOpt.Action, null); + if (input == "c" && chiOpt != null) return ("chi", null); + + // Discard by index + if (int.TryParse(input, out int idx) && idx >= 1 && idx <= sorted.Count) + { + return ("discard", sorted[idx - 1]); + } + + Console.WriteLine("无效输入,请重试"); + } + } +} diff --git a/RuleEngine/MahjongRoom.cs b/RuleEngine/MahjongRoom.cs index 1e14fbb..b7517cc 100644 --- a/RuleEngine/MahjongRoom.cs +++ b/RuleEngine/MahjongRoom.cs @@ -13,17 +13,21 @@ public class MahjongRoom public bool IsFinished { get; private set; } private readonly MahjongDslRoot _rules; private readonly List _ais; + private readonly HumanMahjongPlayer? _human; private readonly MeldsSolver _solver; private readonly MahjongPhaseMachine _phaseMachine; private readonly MahjongScoreEngine _scoreEngine; private readonly bool _autoMode; private readonly Random _rng = Random.Shared; - public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false) + public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false, + string? humanPlayer = null) { _rules = rules; _autoMode = autoMode; _ais = playerNames.Select((n, i) => new RandomMahjongAI(n, new Random(i * 7919))).ToList(); + if (humanPlayer != null) + _human = new HumanMahjongPlayer(humanPlayer); var fanConfig = BuildFanConfig(); _solver = new MeldsSolver(fanConfig); @@ -179,7 +183,9 @@ public class MahjongRoom // Phase 3: Player decides what to do with their drawn hand bool requireWinFan = _rules.WinMinFan > 0; var legalActions = _phaseMachine.GetLegalActions(State, player, requireWinFan); - var (action, tile) = _ais.First(a => a.Name == player).Decide(legalActions, State); + var (action, tile) = _human != null && player == _human.Name + ? _human.Decide(legalActions, State) + : _ais.First(a => a.Name == player).Decide(legalActions, State); if (action == "win") { @@ -285,6 +291,9 @@ public class MahjongRoom if (best.action == "win") { + // Remove the winning tile from pool (it's now part of the winning hand) + State.DiscardPool.Remove(discardTile); + var handWithTile = new List(State.Hands[bp]) { discardTile }; var (wc2, r2582) = GetWinParams(); int wildInHand2 = CountWildcardsInHand(handWithTile); @@ -305,6 +314,9 @@ public class MahjongRoom if (best.action == "pung" || best.action == "ming_kong") { + // Remove the discarded tile from pool (it's now in the meld) + State.DiscardPool.Remove(discardTile); + // Remove tiles from hand (pung: 2, ming_kong: 3) // The discard tile is taken from pool, not from hand int toRemove = best.action == "ming_kong" ? 3 : 2;