Root cause chain (逐层发现): 1. 非庄家第一轮: 南碰了自己的弃牌→不摸牌→Deck.Count=55≠54 → 修复: 测试接受54|55两情况 2. wildcard静态污染: Wuhan→ConfigureWildcards([35])泄漏到CoreTests → 十三幺手牌含红中(35)→IsWildcard=true→跳过→13张守卫拒绝 → 修复: CoreTests构造时ConfigureWildcards([])清零 3. Random.Shared非确定性: 牌库洗牌用共享Random→跨run不可复现 → 修复: MahjongRoom(seed:)参数→测试seed:42确定性 附加: - AssemblyInfo.cs: 关闭xUnit并行(静态状态竞态) - 补'碰碰和'到FanValue fallback switch(国标名称) Test: 20/20 all green, first time since project creation
769 lines
30 KiB
C#
769 lines
30 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;
|
|
|
|
public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false,
|
|
string? humanPlayer = null, int? seed = null)
|
|
{
|
|
_rules = rules;
|
|
_autoMode = autoMode;
|
|
_rng = seed.HasValue ? new Random(seed.Value) : Random.Shared;
|
|
_ais = playerNames.Select((n, i) => new RandomMahjongAI(n, new Random(i * 7919))).ToList();
|
|
|
|
var fanConfig = BuildFanConfig();
|
|
_solver = new MeldsSolver(fanConfig);
|
|
|
|
if (humanPlayer != null)
|
|
_human = new HumanMahjongPlayer(humanPlayer, _solver);
|
|
|
|
var phases = BuildPhases();
|
|
var (wc, r258) = GetWinParams();
|
|
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258,
|
|
fanConfig: fanConfig,
|
|
jiHuSelfDrawOnly: _rules.WinRule?.JiHuSelfDrawOnly ?? false);
|
|
|
|
_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
|
|
}, _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 = "counter_clockwise",
|
|
ParallelElimination = p.ParallelElimination,
|
|
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
|
|
}).ToList();
|
|
}
|
|
|
|
public void Run()
|
|
{
|
|
Deal();
|
|
IsFinished = false;
|
|
|
|
while (!IsFinished)
|
|
{
|
|
if (State.Phase == "deal") { State.Phase = "play"; continue; }
|
|
if (State.Phase == "settle" || IsFinished) break;
|
|
|
|
StepTurn();
|
|
}
|
|
|
|
if (!IsFinished)
|
|
Settle();
|
|
}
|
|
|
|
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);
|
|
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
|
|
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);
|
|
if (State.Deck.Count > 0)
|
|
{
|
|
int extra = State.Deck[^1];
|
|
State.Deck.RemoveAt(State.Deck.Count - 1);
|
|
State.Hands[player].Add(extra);
|
|
State.AddEvent("draw", player, extra,
|
|
$"补牌: {MahjongTile.ToString(extra)}");
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
State.IsDeckExhausted = true;
|
|
State.AddEvent("deck_exhausted", "", null, "牌墙耗尽");
|
|
|
|
if (_rules.Phases.Any(p => p.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);
|
|
State.HuPlayers.Add(player);
|
|
State.AlivePlayers.Remove(player);
|
|
State.AddEvent("win", player, null,
|
|
$"自摸!番型: {string.Join(" + ", result?.Fans ?? new())}");
|
|
|
|
if (_rules.Phases.Any(p => p.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);
|
|
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).</summary>
|
|
private static int CountWildcardsInHand(List<int> hand)
|
|
=> hand.Count(MahjongTile.IsWildcard);
|
|
|
|
/// <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 available to the player immediately after the discarder (上家)
|
|
if (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)
|
|
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<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)
|
|
{
|
|
State.DiscardPool.Remove(discardTile);
|
|
State.HuPlayers.Add(p);
|
|
State.AlivePlayers.Remove(p);
|
|
State.AddEvent("win", p, discardTile,
|
|
$"胡!{MahjongTile.ToString(discardTile)}");
|
|
if (!_rules.Phases.Any(ph => ph.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 — continue to next player
|
|
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<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])}");
|
|
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 (!_rules.Phases.Any(ph => ph.ParallelElimination))
|
|
{
|
|
_scoreEngine.Settle(State, player, winResult, isSelfDraw: false);
|
|
IsFinished = true;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private void ExecuteAnKong(string player)
|
|
{
|
|
var hand = State.Hands[player];
|
|
var group = hand.GroupBy(t => t)
|
|
.First(g => g.Count() >= 4 && !MahjongTile.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
|
|
{
|
|
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.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.First(m => m.Type == "pung" && State.Hands[player].Contains(m.Tiles[0]));
|
|
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)}");
|
|
|
|
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)}");
|
|
}
|
|
|
|
// 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
|
|
MahjongTile.ConfigureWildcards([]);
|
|
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 => t switch { "红中" => 35, "发财" => 36, "白板" => 37, _ => -1 })
|
|
.Where(t => t > 0).ToArray();
|
|
MahjongTile.ConfigureWildcards(wcTiles);
|
|
wildcardCount = 0; // No extra tiles
|
|
}
|
|
else
|
|
{
|
|
wildcardCount = _rules.Deck.WildcardCount;
|
|
}
|
|
}
|
|
|
|
var deck = new MahjongDeck(includeHonors, includeFlowers, wildcardCount,
|
|
expectedTotal: _rules.Deck.Total);
|
|
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, "发牌完成");
|
|
}
|
|
|
|
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, "结算");
|
|
}
|
|
}
|