Files
card-game-engine/RuleEngine/MahjongRoom.cs
xiaoou d5329e5b0c feat: 精分独立计分模式(independent_bonus)
引擎原算法: (底分+精分)×倍数 → 精分被庄家/自摸放大
南昌实际: 底分×倍数 + 精分 → 精分不参与倍数

新增DSL字段 wildcard_rules.scoring.independent_bonus:
- false(默认): 精分混入底分(现有行为,向后兼容)
- true(南昌): 精分独立加在倍数之后

代码: ScoreEngine.Settle → multiplierPart(底分×倍数) + addPart(精分)
自摸: 精分只加给赢家一次(不计入倍数)
点炮: 精分计入pay总额(放炮者全额支付)
2026-07-05 01:37:57 +08:00

924 lines
38 KiB
C#

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<RandomMahjongAI> _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,
WildcardIndependent = _rules.WildcardRules?.Scoring?.IndependentBonus ?? false,
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<int>();
State.Exposed[p] = new List<Meld>();
State.Scores[p] = 0;
}
}
private Dictionary<string, FanConfig> BuildFanConfig()
{
var config = new Dictionary<string, FanConfig>();
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<PhaseConfig> 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<int>();
State.DiscardHistory[player].Add(tile);
}
public List<GameEvent> 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<int>();
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<int>();
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<int>(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;
}
/// <summary>Get wildcard count and 258-pair requirement for current rules.</summary>
private (int wildcardCount, bool require258) GetWinParams()
{
int wc = _rules.WildcardRules != null ? _rules.Deck.WildcardCount : 0;
bool r258 = _rules.WinCondition?.PairMustBe258 ?? false;
return (wc, r258);
}
/// <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)
{
var tiles = new List<int>(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;
}
/// <summary>
/// Let other players react to a discard (pung/kong/win).
/// Returns true if someone reacted (turn passes to them).
/// </summary>
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(t => MahjongTile.ToString(t, State.Wildcards)))}");
// Show 听牌 info
var tingTiles = GetTingTiles(p);
if (tingTiles.Count > 0)
{
var names = tingTiles.Select(t => MahjongTile.ToString(t, State.Wildcards));
Console.WriteLine($" 🀄听牌: {tingTiles.Count}张 ({string.Join(" ", names)})");
}
Console.Write(" 操作: ");
var ops = new List<string>();
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], State.Wildcards)}");
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<int> 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<int>(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], State.Wildcards)}");
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<int> 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<int>(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], State.Wildcards)}");
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<int>();
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<int>(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, "结算");
}
/// <summary>返回玩家可胡牌的所有unique tile列表(听牌检测)。Empty=未听.</summary>
public List<int> GetTingTiles(string player)
{
var result = new List<int>();
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<int>(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;
}
}