本次修复(13/26): - FuFlag传播: PhaseConfig加FuFlag属性+BuildPhases映射 - Excludes/Conflicts引用完整性: 引用的番型名必须存在于fan_types - WildcardBase常量提取: WildcardRegistry引用MahjongTile.WildcardBase - 合规验证扩展: max_fan/phase.type/end_condition/action/priority - FanCalculationPolicy未消费警告 不修复的剩余13个: - 编码常量(MahjongTile domain定义,非配置项) - DSL字段仅文档(POCO有但引擎硬编码等效逻辑) - LOW代码卫生项 --check输出现在会报告: excludes/conflicts引用了不存在的番型(如国标15个)
263 lines
11 KiB
C#
263 lines
11 KiB
C#
namespace RuleEngine.Phase;
|
|
|
|
using RuleEngine.Core;
|
|
using RuleEngine.Dsl;
|
|
using RuleEngine.Patterns;
|
|
|
|
public class PhaseConfig
|
|
{
|
|
public string Name { get; set; } = "";
|
|
public string Type { get; set; } = ""; // "auto", "mahjong_turn"
|
|
public string Action { get; set; } = "";
|
|
public string? Next { get; set; }
|
|
public string TurnOrder { get; set; } = "counter_clockwise";
|
|
public string? FirstPlayer { get; set; }
|
|
public List<ActionOption> SelfActions { get; set; } = new();
|
|
public List<ActionOption> OthersReactions { get; set; } = new();
|
|
public string PriorityPolicy { get; set; } = "highest_wins";
|
|
public string? OnWin { get; set; }
|
|
public List<EndCondition> EndConditions { get; set; } = new();
|
|
public bool ParallelElimination { get; set; }
|
|
public string? OnEliminate { get; set; }
|
|
public FuFlagConfig? FuFlag { get; set; }
|
|
public int MaxFan { get; set; } = int.MaxValue;
|
|
}
|
|
|
|
public class ActionOption
|
|
{
|
|
public string Action { get; set; } = "";
|
|
public int Priority { get; set; }
|
|
public string? Condition { get; set; }
|
|
}
|
|
|
|
public class EndCondition
|
|
{
|
|
public string Type { get; set; } = "";
|
|
public string Action { get; set; } = "";
|
|
}
|
|
|
|
public class MahjongPhaseMachine
|
|
{
|
|
private readonly List<PhaseConfig> _phases;
|
|
private readonly MeldsSolver _solver;
|
|
private readonly int _wildcardCount;
|
|
private readonly bool _require258;
|
|
private readonly Dictionary<string, FanConfig> _fanConfig;
|
|
private readonly bool _jiHuSelfDrawOnly;
|
|
private readonly WildcardRegistry _wildcards;
|
|
private readonly bool _chiAllowed;
|
|
private readonly int _winMinFan;
|
|
public bool ChiAllowed => _chiAllowed;
|
|
|
|
public MahjongPhaseMachine(List<PhaseConfig> phases, MeldsSolver solver,
|
|
int wildcardCount = 0, bool require258 = false,
|
|
Dictionary<string, FanConfig>? fanConfig = null,
|
|
bool jiHuSelfDrawOnly = false,
|
|
WildcardRegistry? wildcards = null,
|
|
int winMinFan = 0)
|
|
{
|
|
_phases = phases;
|
|
_solver = solver;
|
|
_wildcardCount = wildcardCount;
|
|
_require258 = require258;
|
|
_fanConfig = fanConfig ?? new();
|
|
_jiHuSelfDrawOnly = jiHuSelfDrawOnly;
|
|
_wildcards = wildcards ?? new();
|
|
_winMinFan = winMinFan;
|
|
_chiAllowed = phases.Any(p =>
|
|
p.OthersReactions.Any(o => o.Action == "chi"));
|
|
}
|
|
|
|
public PhaseConfig? GetPhase(string name) => _phases.FirstOrDefault(p => p.Name == name);
|
|
|
|
public List<ActionOption> GetLegalActions(MahjongGameState state, string playerId, bool requireWinFan = false)
|
|
{
|
|
var actions = new List<ActionOption>();
|
|
|
|
// Read action lists from DSL play phase (instead of hardcoding)
|
|
var playPhase = _phases.FirstOrDefault(p => p.Name == "play");
|
|
var selfActs = playPhase?.SelfActions ?? new();
|
|
var reactActs = playPhase?.OthersReactions ?? new();
|
|
bool allowDiscard = selfActs.Any(a => a.Action == "discard");
|
|
bool allowAnKong = selfActs.Any(a => a.Action == "an_kong");
|
|
bool allowBuKong = selfActs.Any(a => a.Action == "bu_kong");
|
|
bool allowSelfDrawWin = selfActs.Any(a => a.Action == "win");
|
|
bool allowPung = reactActs.Any(a => a.Action == "pung");
|
|
bool allowMingKong = reactActs.Any(a => a.Action == "ming_kong");
|
|
bool allowReactionWin = reactActs.Any(a => a.Action == "win");
|
|
bool chiAllowed = _chiAllowed;
|
|
|
|
// Helper: evaluate DSL condition string
|
|
bool CheckCondition(ActionOption opt, bool canPung, bool canMingKong, bool canSelfWin, int fanSum)
|
|
{
|
|
if (string.IsNullOrEmpty(opt.Condition)) return true;
|
|
return opt.Condition switch
|
|
{
|
|
"has_punged_pair" => CanBuKong(state, playerId),
|
|
"has_two_same" => canPung,
|
|
"has_three_same" => canMingKong,
|
|
"can_chi" => chiAllowed,
|
|
"can_win" => canSelfWin,
|
|
"can_win_tumo" => canSelfWin,
|
|
"can_win_tumo_and_fan_ge_8" => canSelfWin && fanSum >= _winMinFan,
|
|
"can_win_and_fan_ge_8" => canSelfWin && fanSum >= _winMinFan,
|
|
_ => true // unknown condition → allow (fail-open for safety)
|
|
};
|
|
}
|
|
|
|
// Always can discard (every variant supports this)
|
|
if (allowDiscard)
|
|
{
|
|
foreach (var t in state.Hands[playerId].Distinct())
|
|
actions.Add(new ActionOption { Action = "discard", Priority = 0 });
|
|
}
|
|
|
|
// Check pung/kong/win for last discard (only if DSL allows them)
|
|
if (state.LastDiscard.HasValue && state.LastDiscardPlayer != playerId)
|
|
{
|
|
if (allowPung && CanPung(state, playerId, state.LastDiscard.Value))
|
|
{
|
|
if (CheckCondition(reactActs.First(a => a.Action == "pung"), true, false, false, 0))
|
|
actions.Add(new ActionOption { Action = "pung", Priority = 2 });
|
|
}
|
|
|
|
if (allowMingKong && CanMingKong(state, playerId, state.LastDiscard.Value))
|
|
{
|
|
if (CheckCondition(reactActs.First(a => a.Action == "ming_kong"), false, true, false, 0))
|
|
actions.Add(new ActionOption { Action = "ming_kong", Priority = 3 });
|
|
}
|
|
|
|
if (allowReactionWin)
|
|
{
|
|
var fullHand = FullHand(state, playerId, extraTile: state.LastDiscard.Value);
|
|
int wildInHand = _wildcards.CountWildcards(fullHand);
|
|
var result = _solver.CheckWin(fullHand,
|
|
wildcardCount: wildInHand, require258Pair: _require258);
|
|
if (result != null && result.IsWin)
|
|
{
|
|
// 鸡胡只能自摸 — block discard win if 鸡胡 is the only/lowest fan
|
|
if (_jiHuSelfDrawOnly && result.Fans.Count > 0
|
|
&& (result.Fans.Contains("鸡胡") || result.Fans.All(f => GetFanValue(f) <= 1)))
|
|
{
|
|
// Don't add win action; 鸡胡 can only self-draw
|
|
}
|
|
else if (!requireWinFan || result.Fans.Sum(f => GetFanValue(f)) >= _winMinFan)
|
|
actions.Add(new ActionOption { Action = "win", Priority = 4 });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Self actions: only if DSL allows them
|
|
if (allowAnKong && CanAnKong(state, playerId))
|
|
{
|
|
if (CheckCondition(selfActs.First(a => a.Action == "an_kong"), false, false, false, 0))
|
|
actions.Add(new ActionOption { Action = "an_kong", Priority = 1 });
|
|
}
|
|
if (allowBuKong && CanBuKong(state, playerId))
|
|
{
|
|
if (CheckCondition(selfActs.First(a => a.Action == "bu_kong"), false, false, false, 0))
|
|
actions.Add(new ActionOption { Action = "bu_kong", Priority = 1 });
|
|
}
|
|
|
|
if (allowSelfDrawWin)
|
|
{
|
|
var fullSelfHand = FullHand(state, playerId);
|
|
int wildInSelf = _wildcards.CountWildcards(fullSelfHand);
|
|
var tumoResult = _solver.CheckWin(fullSelfHand,
|
|
wildcardCount: wildInSelf, require258Pair: _require258);
|
|
if (tumoResult != null && tumoResult.IsWin)
|
|
{
|
|
if (!requireWinFan || tumoResult.Fans.Sum(f => GetFanValue(f)) >= _winMinFan)
|
|
actions.Add(new ActionOption { Action = "win", Priority = 4 });
|
|
}
|
|
}
|
|
|
|
actions.Add(new ActionOption { Action = "pass", Priority = 0 });
|
|
return actions;
|
|
}
|
|
|
|
/// <summary>Shared fan value fallback — single source of truth for both PhaseMachine and ScoreEngine.</summary>
|
|
public static int DefaultFanValue(string fanName) => fanName switch
|
|
{
|
|
"清一色" => 24, "混一色" => 6, "对对胡" => 2, "碰碰和" => 6,
|
|
"暗七对" => 8, "七对" => 8, "带幺九" => 2, "十三幺" => 88,
|
|
"鸡胡" => 1,
|
|
_ => 0
|
|
};
|
|
|
|
private int GetFanValue(string fanName)
|
|
{
|
|
if (_fanConfig.TryGetValue(fanName, out var def))
|
|
return def.BaseFan;
|
|
return DefaultFanValue(fanName);
|
|
}
|
|
|
|
private bool CanPung(MahjongGameState state, string playerId, int tile)
|
|
{
|
|
int count = state.Hands[playerId].Count(t => t == tile);
|
|
return count >= 2;
|
|
}
|
|
|
|
private bool CanMingKong(MahjongGameState state, string playerId, int tile)
|
|
{
|
|
int count = state.Hands[playerId].Count(t => t == tile);
|
|
return count >= 3;
|
|
}
|
|
|
|
private bool CanAnKong(MahjongGameState state, string playerId)
|
|
{
|
|
// Check if player has 4 identical tiles in hand (暗杠)
|
|
var hand = state.Hands[playerId];
|
|
return hand.GroupBy(t => t).Any(g => g.Count() >= 4 && !_wildcards.IsWildcard(g.Key));
|
|
}
|
|
|
|
private bool CanBuKong(MahjongGameState state, string playerId)
|
|
{
|
|
// Check if player has an exposed pung and drew the 4th tile
|
|
if (!state.Exposed.ContainsKey(playerId)) return false;
|
|
return state.Exposed[playerId].Any(m => m.Type == "pung" &&
|
|
state.Hands[playerId].Contains(m.Tiles[0]));
|
|
}
|
|
|
|
public bool CanChiCheck(MahjongGameState state, string playerId, int tile)
|
|
{
|
|
if (MahjongTile.IsHonor(tile)) return false;
|
|
int suit = MahjongTile.Suit(tile);
|
|
int rank = MahjongTile.Rank(tile);
|
|
var hand = state.Hands[playerId];
|
|
|
|
// Check two sequential combinations
|
|
bool hasLower = rank >= 3
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 2) > 0
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1) > 0;
|
|
bool hasMiddle = rank >= 2 && rank <= 8
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank - 1) > 0
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1) > 0;
|
|
bool hasUpper = rank <= 7
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 1) > 0
|
|
&& hand.Count(t => MahjongTile.Suit(t) == suit && MahjongTile.Rank(t) == rank + 2) > 0;
|
|
|
|
return hasLower || hasMiddle || hasUpper;
|
|
}
|
|
|
|
public string GetNextPlayer(MahjongGameState state, string currentPlayer)
|
|
{
|
|
var alive = state.AlivePlayers;
|
|
int idx = alive.IndexOf(currentPlayer);
|
|
if (idx < 0) return alive[0];
|
|
int next = (idx + 1) % alive.Count;
|
|
return alive[next];
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|