Files
card-game-engine/RuleEngine/MahjongRoom.cs
xiaoou 4302ec2fd5 feat: 集成测试 + 手动出牌模式 + discart pool幽灵牌修复
集成测试 (10个):
- 发牌: 庄14闲13, 牌墙55
- 回合流转: 庄家第一轮不摸牌, 非庄家摸牌后出牌
- 数据完整性: 10回合后手牌+副露+牌墙+弃牌=108
- 对局完整性: 4种DSL全部不出错完成
- 构造听牌: 完整手牌第一轮自摸

手动出牌 (--human):
- HumanMahjongPlayer: 显示手牌/可选操作/h=胡/p=碰/k=杠/1-N=出牌
- MahjongRoom: humanPlayer参数

修复: DiscardPool碰/胡后不移除→幽灵牌→总牌数溢出
2026-07-04 11:45:54 +08:00

462 lines
17 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;
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);
var phases = BuildPhases();
var (wc, r258) = GetWinParams();
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258);
_scoreEngine = new MahjongScoreEngine(new ScoringConfig
{
Mode = _rules.Scoring.Mode,
MaxCap = _rules.Scoring.MaxCap
}, _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();
}
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)
{
if (State.Deck.Count > 0)
{
int drawn = State.Deck[^1];
State.Deck.RemoveAt(State.Deck.Count - 1);
State.Hands[player].Add(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();
int wildInHand = CountWildcardsInHand(State.Hands[player]);
var result = _solver.CheckWin(State.Hands[player],
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;
State.DiscardPool.Add(tile.Value);
State.AddEvent("discard", player, tile.Value,
$"出牌: {MahjongTile.ToString(tile.Value)}");
}
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>
/// 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)
{
// Collect claims from all alive players except the discarder
var claims = new List<(string player, int priority, string action)>();
string discarder = State.LastDiscardPlayer ?? "";
// Check in player order (starting after discarder)
int startIdx = State.PlayerOrder.IndexOf(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);
string? claim = null;
int priority = 0;
// Check win first (highest priority)
var handWithTile = new List<int>(State.Hands[p]) { discardTile };
var (wc, r258) = GetWinParams();
int wildInHand = CountWildcardsInHand(handWithTile);
var winResult = _solver.CheckWin(handWithTile,
wildcardCount: wildInHand, require258Pair: r258);
if (winResult != null && winResult.IsWin)
claim = "win";
else if (sameCount >= 3)
claim = "ming_kong";
else if (sameCount >= 2)
claim = "pung";
if (claim == null) continue;
priority = claim switch { "win" => 4, "ming_kong" => 3, "pung" => 2, _ => 0 };
// For same priority, closer seat wins (first claim in order)
// For higher priority, it overrides all previous claims
if (claims.Count == 0 || priority > claims[0].priority)
claims.Add((p, priority, claim));
// Same priority: first claimant wins (closer to discarder in seat order)
// Different (lower) priority: ignored — higher priority already claimed
}
if (claims.Count == 0) return false;
var best = claims[0];
string bp = best.player;
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<int>(State.Hands[bp]) { discardTile };
var (wc2, r2582) = GetWinParams();
int wildInHand2 = CountWildcardsInHand(handWithTile);
var winResult = _solver.CheckWin(handWithTile,
wildcardCount: wildInHand2, require258Pair: r2582);
State.HuPlayers.Add(bp);
State.AlivePlayers.Remove(bp);
State.AddEvent("win", bp, discardTile,
$"胡!{MahjongTile.ToString(discardTile)}");
if (!_rules.Phases.Any(ph => ph.ParallelElimination))
{
_scoreEngine.Settle(State, bp, winResult!, isSelfDraw: false);
IsFinished = true;
}
return true;
}
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;
for (int r = 0; r < toRemove; r++)
State.Hands[bp].Remove(discardTile);
// Create exposed meld
int meldSize = best.action == "ming_kong" ? 4 : 3;
var meldTiles = Enumerable.Repeat(discardTile, meldSize).ToList();
State.Exposed[bp].Add(new Meld
{
Type = best.action,
Tiles = meldTiles,
SourcePlayer = discarder
});
State.CurrentPlayer = bp;
State.LastDiscard = null;
State.LastDiscardPlayer = null;
State.AddEvent(best.action, bp, discardTile,
best.action == "pung" ? "碰!" : "明杠!");
// After pung/kong, the reacting player must discard a tile
var hand = State.Hands[bp];
if (hand.Count > 0)
{
int tileToDiscard = hand[Random.Shared.Next(hand.Count)];
State.Hands[bp].Remove(tileToDiscard);
State.LastDiscard = tileToDiscard;
State.LastDiscardPlayer = bp;
State.DiscardPool.Add(tileToDiscard);
State.AddEvent("discard", bp, tileToDiscard,
$"出牌: {MahjongTile.ToString(tileToDiscard)}");
}
return true;
}
return false;
}
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;
int wildcardCount = _rules.WildcardRules != null ? _rules.Deck.WildcardCount : 0;
var deck = new MahjongDeck(includeHonors, includeFlowers, wildcardCount);
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 hasWildcard = _rules.WildcardRules != null;
int wildcardCount = hasWildcard ? _rules.Deck.WildcardCount : 0;
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 r = _solver.CheckWin(hand, newTile: tile,
wildcardCount: wildcardCount, 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, "结算");
}
}