namespace RuleEngine; using RuleEngine.AI; using RuleEngine.Core; using RuleEngine.Dsl; using RuleEngine.Patterns; using RuleEngine.Phase; using RuleEngine.Scoring; public class MahjongRoom { public MahjongGameState State { get; private set; } = new(); 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; private readonly PhaseConfig _playPhase; 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(); var fanConfig = BuildFanConfig(); _solver = new MeldsSolver(fanConfig, State.Wildcards); if (humanPlayer != null) _human = new HumanMahjongPlayer(humanPlayer, _solver, includeHonors: _rules.Deck.IncludeHonors); var phases = BuildPhases(); _playPhase = phases.FirstOrDefault(p => p.Name == "play") ?? new PhaseConfig(); var (wc, r258) = GetWinParams(); _phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258, fanConfig: fanConfig, jiHuSelfDrawOnly: _rules.WinRule?.JiHuSelfDrawOnly ?? false, wildcards: State.Wildcards, winMinFan: _rules.WinMinFan); _scoreEngine = new MahjongScoreEngine(new ScoringConfig { Mode = _rules.Scoring.Mode, MaxCap = _rules.Scoring.MaxCap, FanStacking = _rules.FanStacking, PerWildcardBonus = _rules.WildcardRules?.Scoring?.PerWildcardInWin ?? 0, JiHuSelfDrawOnly = _rules.WinRule?.JiHuSelfDrawOnly ?? false, FlowerNormalScore = _rules.FlowerRules?.Scoring?.Normal ?? 0, FlowerMatchScore = _rules.FlowerRules?.Scoring?.Matching?.Value ?? 0, FlowerMapping = _rules.FlowerRules?.Scoring?.Matching?.Mapping, SelfDrawMultiplier = _rules.Scoring.SelfDrawMultiplier, DiscardWinMultiplier = _rules.Scoring.DiscardWinMultiplier, DealerMultiplier = _rules.Scoring.DealerMultiplier }, _solver, fanConfig); // Init state State.PlayerOrder = playerNames.ToList(); State.AlivePlayers = playerNames.ToList(); State.Dealer = playerNames[0]; State.CurrentPlayer = playerNames[0]; State.Phase = "deal"; foreach (var p in playerNames) { State.Hands[p] = new List(); State.Exposed[p] = new List(); State.Scores[p] = 0; } } private Dictionary BuildFanConfig() { var config = new Dictionary(); foreach (var f in _rules.FanTypes) { config[f.Name] = new FanConfig { Name = f.Name, BaseFan = f.BaseFan, Level = f.Level, Excludes = f.Excludes ?? new(), Conflicts = f.Conflicts ?? new(), Condition = f.Condition }; } return config; } private List BuildPhases() { return _rules.Phases.Select(p => new PhaseConfig { Name = p.Name, Type = p.Type, Action = p.Action ?? "", Next = p.Next, TurnOrder = p.TurnOrder ?? "counter_clockwise", ParallelElimination = p.ParallelElimination, OnEliminate = p.OnEliminate, FuFlag = p.FuFlag, SelfActions = p.SubPhases?.SelfAction?.Options?.Select(o => new ActionOption { Action = o.Action, Priority = o.Priority, Condition = o.Condition }).ToList() ?? new(), OthersReactions = p.SubPhases?.OthersReaction?.Options?.Select(o => new ActionOption { Action = o.Action, Priority = o.Priority, Condition = o.Condition }).ToList() ?? new(), PriorityPolicy = p.SubPhases?.OthersReaction?.PriorityPolicy ?? "highest_wins", OnWin = p.SubPhases?.OthersReaction?.OnWin, EndConditions = p.EndConditions?.Select(e => new EndCondition { Type = e.Type, Action = e.Action }).ToList() ?? new() }).ToList(); } public void Run() { Deal(); IsFinished = false; while (!IsFinished) { if (State.Phase == "deal") { State.Phase = "play"; continue; } if (State.Phase == "settle" || IsFinished) break; StepTurn(); } } private void RecordDiscard(string player, int tile) { State.DiscardPool.Add(tile); if (!State.DiscardHistory.ContainsKey(player)) State.DiscardHistory[player] = new List(); State.DiscardHistory[player].Add(tile); } public List StepTurn() { State.RecentEvents.Clear(); var player = State.CurrentPlayer; // Phase 1: Check reactions to last discard BEFORE drawing if (State.LastDiscard.HasValue && State.LastDiscardPlayer != null) { bool reacted = HandleDiscardReactions(State.LastDiscard.Value); if (reacted) { AdvancePlayer(); return State.RecentEvents; } State.LastDiscard = null; State.LastDiscardPlayer = null; } // Phase 2: Draw card (skip for dealer on first turn — they already have 14) bool isDealerFirstTurn = (player == State.Dealer && State.RoundNumber == 0); State.IsLastTile = State.Deck.Count == 1; // 海底: deck has exactly 1 tile left if (isDealerFirstTurn) State.LastDrawnTile = null; else { if (State.Deck.Count > 0) { int drawn = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[player].Add(drawn); State.LastDrawnTile = drawn; State.AddEvent("draw", player, drawn, $"摸牌: {MahjongTile.ToString(drawn)}"); // Check flower — keep replacing until non-flower or deck empty if (MahjongTile.IsFlower(drawn)) { State.FlowerReplaced++; State.AddEvent("flower_drawn", player, drawn, $"花牌 {MahjongTile.ToString(drawn)} → 补牌"); if (!State.FlowerPool.ContainsKey(player)) State.FlowerPool[player] = new List(); State.FlowerPool[player].Add(drawn); State.Hands[player].Remove(drawn); // Keep drawing replacements until we get a non-flower int extra = 0; while (State.Deck.Count > 0) { extra = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); if (!MahjongTile.IsFlower(extra)) { State.Hands[player].Add(extra); State.AddEvent("draw", player, extra, $"补牌: {MahjongTile.ToString(extra)}"); break; } // Replacement is also a flower → add to pool, keep going State.FlowerReplaced++; if (!State.FlowerPool.ContainsKey(player)) State.FlowerPool[player] = new List(); State.FlowerPool[player].Add(extra); State.AddEvent("flower_drawn", player, extra, $"补花牌 {MahjongTile.ToString(extra)} → 再补"); } } } else { State.IsDeckExhausted = true; State.AddEvent("deck_exhausted", "", null, "牌墙耗尽"); if (_playPhase.ParallelElimination) { CheckHuaZhu(); CheckTing(); } Settle(); return State.RecentEvents; } } // end if (!isDealerFirstTurn) // 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) = _human != null && player == _human.Name ? _human.Decide(legalActions, State) : _ais.First(a => a.Name == player).Decide(legalActions, State); if (action == "win") { var (wc, r258) = GetWinParams(); var fullHand = FullHand(State, player); int wildInHand = CountWildcardsInHand(fullHand); var result = _solver.CheckWin(fullHand, wildcardCount: wildInHand, require258Pair: r258); if (result != null) { // 单钓将: drawn tile completed a pair from a singleton if (State.LastDrawnTile.HasValue && result.PairTiles.Count == 2) { int drawn = State.LastDrawnTile.Value; if (result.PairTiles.Contains(drawn)) { int other = result.PairTiles[0] == drawn ? result.PairTiles[1] : result.PairTiles[0]; // Before drawing the winning tile, hand had 1 of the other pair tile var handBefore = new List(fullHand); handBefore.Remove(drawn); if (handBefore.Count(t => t == other) == 1) State.IsSingleWait = true; } } result.Fans = _solver.IdentifyFans(result, State); // event-based fans } State.HuPlayers.Add(player); State.AlivePlayers.Remove(player); State.AddEvent("win", player, null, $"自摸!番型: {string.Join(" + ", result?.Fans ?? new())}"); if (_playPhase.ParallelElimination) { if (State.AlivePlayers.Count <= 1) { Settle(); return State.RecentEvents; } } else { _scoreEngine.Settle(State, player, result!, isSelfDraw: true); Settle(); return State.RecentEvents; } } else if (action == "discard" && tile.HasValue) { State.Hands[player].Remove(tile.Value); State.LastDiscard = tile.Value; State.LastDiscardPlayer = player; RecordDiscard(player, tile.Value); // Clear event flags after a normal discard State.IsKongDraw = false; State.IsRobbedKong = false; State.FuFlags[player] = false; // clear fu_flag on own discard if (State.IsFirstTurn && player == State.Dealer) State.IsFirstTurn = false; // dealer's first turn complete State.AddEvent("discard", player, tile.Value, $"出牌: {MahjongTile.ToString(tile.Value)}"); } else if (action == "an_kong") { ExecuteAnKong(player); } else if (action == "bu_kong") { ExecuteBuKong(player); } AdvancePlayer(); return State.RecentEvents; } /// Get wildcard count and 258-pair requirement for current rules. private (int wildcardCount, bool require258) GetWinParams() { int wc = _rules.WildcardRules != null ? _rules.Deck.WildcardCount : 0; bool r258 = _rules.WinCondition?.PairMustBe258 ?? false; return (wc, r258); } /// 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) { var tiles = new List(state.Hands[player]); if (extraTile.HasValue) tiles.Add(extraTile.Value); if (state.Exposed.TryGetValue(player, out var melds)) foreach (var m in melds) tiles.AddRange(m.Tiles.Where(t => t != -1)); return tiles; } /// /// Let other players react to a discard (pung/kong/win). /// Returns true if someone reacted (turn passes to them). /// private bool HandleDiscardReactions(int discardTile) { string discarder = State.LastDiscardPlayer ?? ""; int startIdx = State.PlayerOrder.IndexOf(discarder); // Check in player order (starting after discarder) for (int offset = 1; offset < State.PlayerOrder.Count; offset++) { string p = State.PlayerOrder[(startIdx + offset) % State.PlayerOrder.Count]; if (!State.AlivePlayers.Contains(p)) continue; if (p == discarder) continue; int sameCount = State.Hands[p].Count(t => t == discardTile); bool canWin = false, canKong = sameCount >= 3, canPung = sameCount >= 2; bool canChi = false; // Chi: only if DSL allows it + immediate next player + non-honor tile if (_phaseMachine.ChiAllowed && offset == 1 && !MahjongTile.IsHonor(discardTile)) canChi = _phaseMachine.CanChiCheck(State, p, discardTile); // Check win var fullHand = FullHand(State, p, extraTile: discardTile); var (wc, r258) = GetWinParams(); int wildInHand = CountWildcardsInHand(fullHand); var winResult = _solver.CheckWin(fullHand, wildcardCount: wildInHand, require258Pair: r258); if (winResult != null && winResult.IsWin) { winResult.Fans = _solver.IdentifyFans(winResult, State); // event-based fans canWin = true; } // Human player: ask what they want to do if (_human != null && p == _human.Name && (canWin || canKong || canPung || canChi)) { Console.WriteLine(); Console.WriteLine($"=== {p} 可以反应 ==="); Console.WriteLine($" 对手 {discarder} 出了: {MahjongTile.ToString(discardTile)}"); Console.WriteLine($" 你的手牌: {string.Join(" ", State.Hands[p].OrderBy(t => t).Select(MahjongTile.ToString))}"); Console.Write(" 操作: "); var ops = new List(); if (canWin) ops.Add("h(胡)"); if (canKong) ops.Add("k(杠)"); if (canPung) ops.Add("p(碰)"); if (canChi) ops.Add("c(吃)"); ops.Add("Enter(过)"); Console.WriteLine(string.Join(" ", ops)); Console.Write("> "); string? input = Console.ReadLine()?.Trim().ToLower(); if (input == "h" && canWin) { // Check fu_flag: if player passed a win earlier, block it if (State.FuFlags.GetValueOrDefault(p, false)) { Console.WriteLine(" ⚠ 过水——本轮不能胡同一张牌"); continue; } State.DiscardPool.Remove(discardTile); State.HuPlayers.Add(p); State.AlivePlayers.Remove(p); State.AddEvent("win", p, discardTile, $"胡!{MahjongTile.ToString(discardTile)}"); if (!_playPhase.ParallelElimination) { _scoreEngine.Settle(State, p, winResult!, isSelfDraw: false); IsFinished = true; } return true; } if ((input == "k" && canKong) || (input == "p" && canPung)) { string action = input == "k" ? "ming_kong" : "pung"; return ExecutePungKongHuman(p, discardTile, action); } if (input == "c" && canChi) { return ExecuteChi(p, discardTile); } // Human passed — set fu_flag if they could have won if (canWin) State.FuFlags[p] = true; continue; } // AI player: auto-decide if (canWin) return ExecuteWinClaim(p, discardTile, winResult!); if (canKong || canPung) return ExecutePungKong(p, discardTile, canKong ? "ming_kong" : "pung"); if (canChi) return ExecuteAiChi(p, discardTile); } return false; } private bool ExecutePungKongHuman(string p, int discardTile, string action) { string discarder = State.LastDiscardPlayer ?? ""; State.DiscardPool.Remove(discardTile); int toRemove = action == "ming_kong" ? 3 : 2; for (int r = 0; r < toRemove; r++) State.Hands[p].Remove(discardTile); int meldSize = action == "ming_kong" ? 4 : 3; State.Exposed[p].Add(new Meld { Type = action, Tiles = Enumerable.Repeat(discardTile, meldSize).ToList(), SourcePlayer = discarder }); State.CurrentPlayer = p; State.LastDiscard = null; State.LastDiscardPlayer = null; State.AddEvent(action, p, discardTile, action == "pung" ? "碰!" : "明杠!"); var hand = State.Hands[p]; Console.WriteLine($" 碰/杠后手牌 ({hand.Count}张):"); var sorted = hand.OrderBy(t => t).ToList(); for (int i = 0; i < sorted.Count; i++) Console.WriteLine($" [{i + 1}] {MahjongTile.ToString(sorted[i])}"); Console.Write($" 请出牌 (1-{hand.Count}): "); int discardIdx; while (!int.TryParse(Console.ReadLine(), out discardIdx) || discardIdx < 1 || discardIdx > sorted.Count) Console.Write(" 无效,重试: "); int tileToDiscard = sorted[discardIdx - 1]; State.Hands[p].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = p; RecordDiscard(p, tileToDiscard); State.AddEvent("discard", p, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); return true; } private bool ExecuteChi(string p, int discardTile) { string discarder = State.LastDiscardPlayer ?? ""; State.DiscardPool.Remove(discardTile); int suit = MahjongTile.Suit(discardTile); int rank = MahjongTile.Rank(discardTile); var hand = State.Hands[p]; // Find which two tiles form the chi sequence List toRemove = new(); if (rank >= 3 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 2) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 2)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)); } else if (rank >= 2 && rank <= 8 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)); } else if (rank <= 7 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 2)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 2)); } foreach (var t in toRemove) State.Hands[p].Remove(t); var chiTiles = new List(toRemove) { discardTile }; chiTiles.Sort(); State.Exposed[p].Add(new Meld { Type = "chi", Tiles = chiTiles, SourcePlayer = discarder }); State.CurrentPlayer = p; State.LastDiscard = null; State.LastDiscardPlayer = null; State.AddEvent("chi", p, discardTile, $"吃!{string.Join(",", chiTiles.Select(MahjongTile.ToString))}"); // Human must discard after chi var remainingHand = State.Hands[p]; Console.WriteLine($" 吃后手牌 ({remainingHand.Count}张):"); var sorted = remainingHand.OrderBy(t => t).ToList(); for (int i = 0; i < sorted.Count; i++) Console.WriteLine($" [{i + 1}] {MahjongTile.ToString(sorted[i])}"); Console.Write($" 请出牌 (1-{remainingHand.Count}): "); int discardIdx; while (!int.TryParse(Console.ReadLine(), out discardIdx) || discardIdx < 1 || discardIdx > sorted.Count) Console.Write(" 无效,重试: "); int tileToDiscard = sorted[discardIdx - 1]; State.Hands[p].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = p; RecordDiscard(p, tileToDiscard); State.AddEvent("discard", p, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); return true; } private bool ExecuteAiChi(string p, int discardTile) { string discarder = State.LastDiscardPlayer ?? ""; State.DiscardPool.Remove(discardTile); int suit = MahjongTile.Suit(discardTile); int rank = MahjongTile.Rank(discardTile); var hand = State.Hands[p]; List toRemove = new(); if (rank >= 3 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 2) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 2)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)); } else if (rank >= 2 && rank <= 8 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)); } else if (rank <= 7 && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1) && hand.Any(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 2)) { toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1)); toRemove.Add(hand.First(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 2)); } foreach (var t in toRemove) State.Hands[p].Remove(t); var chiTiles = new List(toRemove) { discardTile }; chiTiles.Sort(); State.Exposed[p].Add(new Meld { Type = "chi", Tiles = chiTiles, SourcePlayer = discarder }); State.CurrentPlayer = p; State.LastDiscard = null; State.LastDiscardPlayer = null; State.AddEvent("chi", p, discardTile, $"吃!{string.Join(",", chiTiles.Select(MahjongTile.ToString))}"); if (hand.Count > 0) { int tileToDiscard = hand[_rng.Next(hand.Count)]; State.Hands[p].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = p; RecordDiscard(p, tileToDiscard); State.AddEvent("discard", p, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); } return true; } private bool ExecuteWinClaim(string player, int discardTile, MeldsResult winResult) { State.DiscardPool.Remove(discardTile); State.HuPlayers.Add(player); State.AlivePlayers.Remove(player); State.AddEvent("win", player, discardTile, $"胡!{MahjongTile.ToString(discardTile)}"); if (!_playPhase.ParallelElimination) { _scoreEngine.Settle(State, player, winResult, isSelfDraw: false); Settle(); } return true; } private void ExecuteAnKong(string player) { var hand = State.Hands[player]; var group = hand.GroupBy(t => t) .FirstOrDefault(g => g.Count() >= 4 && !State.Wildcards.IsWildcard(g.Key)); if (group == null) return; // defensive: PhaseMachine should prevent this int tile = group.Key; for (int i = 0; i < 4; i++) State.Hands[player].Remove(tile); State.Exposed[player].Add(new Meld { Type = "kong_an", Tiles = Enumerable.Repeat(tile, 4).ToList(), IsConcealed = true }); State.AddEvent("an_kong", player, tile, $"暗杠!{MahjongTile.ToString(tile)}"); // Kong draw if (State.Deck.Count > 0) { int drawn = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[player].Add(drawn); State.LastDrawnTile = drawn; State.IsKongDraw = true; // 杠后摸牌标志 State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); } // Discard one tile after kong if (State.Hands[player].Count > 0) { int tileToDiscard = _human != null && player == _human.Name ? HumanPickDiscard(player) : State.Hands[player][_rng.Next(State.Hands[player].Count)]; State.Hands[player].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = player; RecordDiscard(player, tileToDiscard); State.AddEvent("discard", player, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); } } private void ExecuteBuKong(string player) { var exposed = State.Exposed[player]; var pung = exposed.FirstOrDefault(m => m.Type == "pung" && State.Hands[player].Contains(m.Tiles[0])); if (pung == null) return; // defensive: PhaseMachine should prevent this int tile = pung.Tiles[0]; State.Hands[player].Remove(tile); pung.Type = "kong_bu"; pung.Tiles.Add(tile); State.AddEvent("bu_kong", player, tile, $"加杠!{MahjongTile.ToString(tile)}"); State.IsRobbedKong = true; // 抢杠标志 if (State.Deck.Count > 0) { int drawn = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[player].Add(drawn); State.LastDrawnTile = drawn; State.IsKongDraw = true; State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); } // Discard after kong if (State.Hands[player].Count > 0) { int tileToDiscard = _human != null && player == _human.Name ? HumanPickDiscard(player) : State.Hands[player][_rng.Next(State.Hands[player].Count)]; State.Hands[player].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = player; RecordDiscard(player, tileToDiscard); State.AddEvent("discard", player, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); } } private int HumanPickDiscard(string player) { var hand = State.Hands[player]; var sorted = hand.OrderBy(t => t).ToList(); Console.WriteLine($" 杠后手牌 ({hand.Count}张):"); for (int i = 0; i < sorted.Count; i++) Console.WriteLine($" [{i + 1}] {MahjongTile.ToString(sorted[i])}"); Console.Write($" 请出牌 (1-{hand.Count}): "); int idx; while (!int.TryParse(Console.ReadLine(), out idx) || idx < 1 || idx > sorted.Count) Console.Write(" 无效,重试: "); return sorted[idx - 1]; } private bool ExecutePungKong(string player, int discardTile, string action) { State.DiscardPool.Remove(discardTile); int toRemove = action == "ming_kong" ? 3 : 2; for (int r = 0; r < toRemove; r++) State.Hands[player].Remove(discardTile); int meldSize = action == "ming_kong" ? 4 : 3; State.Exposed[player].Add(new Meld { Type = action, Tiles = Enumerable.Repeat(discardTile, meldSize).ToList(), SourcePlayer = State.LastDiscardPlayer ?? "" }); State.CurrentPlayer = player; State.LastDiscard = null; State.LastDiscardPlayer = null; State.AddEvent(action, player, discardTile, action == "pung" ? "碰!" : "明杠!"); // AI discards after pung/kong var hand = State.Hands[player]; if (hand.Count > 0) { int tileToDiscard = hand[_rng.Next(hand.Count)]; State.Hands[player].Remove(tileToDiscard); State.LastDiscard = tileToDiscard; State.LastDiscardPlayer = player; RecordDiscard(player, tileToDiscard); State.AddEvent("discard", player, tileToDiscard, $"出牌: {MahjongTile.ToString(tileToDiscard)}"); } return true; } private void AdvancePlayer() { int idx = State.PlayerOrder.IndexOf(State.CurrentPlayer); for (int i = 0; i < State.PlayerOrder.Count; i++) { int next = (idx + 1 + i) % State.PlayerOrder.Count; if (State.AlivePlayers.Contains(State.PlayerOrder[next])) { State.CurrentPlayer = State.PlayerOrder[next]; State.RoundNumber++; return; } } // No alive players IsFinished = true; } public void Deal() { int perPlayer = _rules.Deal.CardsPerPlayer; bool includeFlowers = _rules.Deck.IncludeFlowers; bool includeHonors = _rules.Deck.IncludeHonors; // Reset wildcards — always clear to avoid test state leak State.Wildcards.Clear(); int wildcardBase = _rules.WildcardRules?.WildcardEncoding ?? 50; State.Wildcards.WildcardBase = wildcardBase; int wildcardCount = 0; if (_rules.WildcardRules != null) { if (_rules.WildcardRules.Type == "fixed" && _rules.WildcardRules.Tiles != null) { // Fixed wildcards: specified tiles ARE the wildcards, not extra tiles var wcTiles = _rules.WildcardRules.Tiles .Select(t => WildcardRegistry.NameToEncoding(t) ?? -1) .Where(t => t > 0).ToArray(); State.Wildcards.AddRange(wcTiles); wildcardCount = 0; // No extra tiles } else { wildcardCount = _rules.Deck.WildcardCount; } } var deck = new MahjongDeck(includeHonors, includeFlowers, wildcardCount, expectedTotal: _rules.Deck.Total, wildcardBase: wildcardBase); deck.Shuffle(_rng); State.Deck = deck.Tiles; State.AddEvent("deal_start", "", null, $"发牌 — {deck.Count}张牌({(includeHonors ? "+字" : "")}{(includeFlowers ? "+花" : "")}{(wildcardCount > 0 ? "+癞子" : "")})"); foreach (var p in State.PlayerOrder) { int count = p == State.Dealer ? perPlayer + _rules.Deal.DealerExtra : perPlayer; for (int i = 0; i < count; i++) { int tile = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); State.Hands[p].Add(tile); } } State.AddEvent("deal_done", "", null, "发牌完成"); // 圈风/门风: assign seat winds based on player order relative to dealer int[] winds = { 31, 32, 33, 34 }; // 东南西北 int dealerIdx = State.PlayerOrder.IndexOf(State.Dealer); for (int i = 0; i < State.PlayerOrder.Count; i++) { int windIdx = (i - dealerIdx + 4) % 4; State.SeatWinds[State.PlayerOrder[i]] = winds[windIdx]; } // Random wildcard (骰子翻牌定精): flip one tile from remaining deck to determine 精 if (_rules.WildcardRules?.Type == "random" && State.Deck.Count > 0) { int revealed = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); int count = _rules.WildcardRules.RandomCount > 0 ? _rules.WildcardRules.RandomCount : 2; var wildcardTiles = WildcardRegistry.ComputeNextTiles(revealed, count); State.Wildcards.AddRange(wildcardTiles); State.Wildcards.RevealedTile = revealed; State.AddEvent("reveal_wildcard", "", revealed, $"翻牌定精: {MahjongTile.ToString(revealed)} → 精: {string.Join(", ", wildcardTiles.Select(t => MahjongTile.ToString(t, State.Wildcards)))}"); } // Set first-turn flags for event-based fans State.IsFirstTurn = true; State.DealerOnFirstTurn = State.Dealer; // Replace flowers in initial hands BEFORE game starts. // Triggers for: Guangdong (replace_tiles: BEFORE_GAME_START), // Guobiao (deal action: deal_cards_with_flowers), or any variant with flowers. bool replaceInitialFlowers = _rules.FlowerRules != null && includeFlowers && (string.Equals(_rules.FlowerRules.ReplaceTiles, "BEFORE_GAME_START", StringComparison.OrdinalIgnoreCase) || string.Equals(_playPhase.Action, "deal_cards_with_flowers", StringComparison.OrdinalIgnoreCase)); if (replaceInitialFlowers) { int replaced = 0; foreach (var p in State.PlayerOrder) { var hand = State.Hands[p]; while (true) { var flower = hand.FirstOrDefault(MahjongTile.IsFlower); if (flower == 0) break; // no more flowers hand.Remove(flower); if (!State.FlowerPool.ContainsKey(p)) State.FlowerPool[p] = new List(); State.FlowerPool[p].Add(flower); if (State.Deck.Count > 0) { int extra = State.Deck[^1]; State.Deck.RemoveAt(State.Deck.Count - 1); hand.Add(extra); replaced++; } else break; // deck empty, stop } } if (replaced > 0) State.AddEvent("flower_replace", "", null, $"开局换花牌: {replaced}张"); } } private void CheckHuaZhu() { foreach (var p in State.AlivePlayers) { var suits = State.Hands[p] .Where(t => MahjongTile.IsNumbered(t)) .Select(MahjongTile.Suit) .Distinct() .Count(); if (suits == 3) State.AddEvent("hua_zhu", p, null, "花猪!三色齐全"); else State.AddEvent("hua_zhu_ok", p, null, $"✓ {suits}种花色"); } } private void CheckTing() { bool require258 = _rules.WinCondition?.PairMustBe258 ?? false; // All possible tiles that could be drawn (numbered 1-9 across 3 suits + 7 honors) var allPossibleTiles = MahjongTile.AllTiles( includeHonors: _rules.Deck.IncludeHonors, includeFlowers: false); foreach (var p in State.AlivePlayers) { bool ting = false; var hand = State.Hands[p]; // For each possible tile we could draw, check if hand + tile is a win foreach (var tile in allPossibleTiles) { // Count how many of this tile exist (4 max per tile type) int alreadyHave = hand.Count(t => t == tile); if (alreadyHave >= 4) continue; // can't draw more of this tile var handWithTile = new List(hand) { tile }; int wc = CountWildcardsInHand(handWithTile); var r = _solver.CheckWin(hand, newTile: tile, wildcardCount: wc, require258Pair: require258); if (r != null && r.IsWin) { ting = true; break; } } if (ting) State.AddEvent("ting_checked", p, null, "✓ 听牌"); else State.AddEvent("ting_failed", p, null, "❌ 未听牌"); } } private void Settle() { IsFinished = true; State.Phase = "settle"; State.AddEvent("settle", "", null, "结算"); } /// 返回玩家可胡牌的所有unique tile列表(听牌检测)。Empty=未听. public List GetTingTiles(string player) { var result = new List(); bool require258 = _rules.WinCondition?.PairMustBe258 ?? false; var allPossible = MahjongTile.AllTiles(_rules.Deck.IncludeHonors, false); var hand = State.Hands[player]; foreach (var tile in allPossible) { int alreadyHave = hand.Count(t => t == tile); if (alreadyHave >= 4) continue; var checkHand = new List(hand) { tile }; int wc = CountWildcardsInHand(checkHand); var r = _solver.CheckWin(hand, newTile: tile, wildcardCount: wc, require258Pair: require258); if (r != null && r.IsWin) result.Add(tile); } return result; } }