feat: 四DSL 100%规则合规→零警告
全部番型识别器补全: - 结构性:将一色/大四喜/大三元/小四喜/小三元/一色四同顺/五门齐/全求人/鸡胡/无字 - 事件型:杠上开花/海底捞月/抢杠胡/天胡/地胡(新增GameState事件标志位) - 条件型:癞子胡(hand_contains_wildcard→IdentifyFans读取state.Wildcards) GameState新增:IsKongDraw/IsLastTile/IsRobbedKong/IsFirstTurn/DealerOnFirstTurn MahjongRoom:杠后摸牌/加杠/海底/首轮设置标志+discard清除 fu_flag过水机制:pass可胡牌时设dirty→下轮自己出牌时清除→阻挡对同一张牌repeat胡 IdentifyFans签名升级:result+state?→state传null时仅结构识别,传state时附加事件/条件/状态番型 ValidateRuleCompliance:enginePatterns补全39种番型→4DSL全零警告
This commit is contained in:
@ -35,6 +35,13 @@ public class MahjongGameState
|
||||
public int? LastDrawnTile { get; set; }
|
||||
public WildcardRegistry Wildcards { get; set; } = new();
|
||||
|
||||
// Event flags for event-based fan types (杠上开花/海底捞月/抢杠胡/天胡/地胡)
|
||||
public bool IsKongDraw { get; set; } // 杠后摸牌 — set after kong draw, cleared on next discard
|
||||
public bool IsLastTile { get; set; } // 海底 — set when deck has 1 tile before draw
|
||||
public bool IsRobbedKong { get; set; } // 抢杠 — set when someone adds to pung (bu_kong)
|
||||
public bool IsFirstTurn { get; set; } // 第一轮 — set for dealer after deal, non-dealer after first draw
|
||||
public string? DealerOnFirstTurn { get; set; } // 庄家首轮(用于天胡判断)
|
||||
|
||||
public MahjongGameState Clone()
|
||||
{
|
||||
return new MahjongGameState
|
||||
@ -59,7 +66,12 @@ public class MahjongGameState
|
||||
RecentEvents = new List<GameEvent>(RecentEvents),
|
||||
FlowerReplaced = FlowerReplaced,
|
||||
LastDrawnTile = LastDrawnTile,
|
||||
Wildcards = Wildcards.Clone()
|
||||
Wildcards = Wildcards.Clone(),
|
||||
IsKongDraw = IsKongDraw,
|
||||
IsLastTile = IsLastTile,
|
||||
IsRobbedKong = IsRobbedKong,
|
||||
IsFirstTurn = IsFirstTurn,
|
||||
DealerOnFirstTurn = DealerOnFirstTurn
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -62,9 +62,14 @@ public class CapabilityRegistry
|
||||
var enginePatterns = new HashSet<string>
|
||||
{
|
||||
"清一色","混一色","字一色","对对胡","碰碰胡","碰碰和","暗七对","七对",
|
||||
"带幺九","全带幺","混幺九","缺一门","平胡","断幺九",
|
||||
"带幺九","全带幺","混幺九","缺一门","平胡","平和","断幺九",
|
||||
"全大","全中","全小","全双","大于五","小于五",
|
||||
"十三幺","全不靠","一色双龙会" // special patterns from CheckWin
|
||||
"十三幺","全不靠","一色双龙会","连七对",
|
||||
"将一色","大四喜","大三元","小四喜","小三元",
|
||||
"一色四同顺","五门齐","全求人","门前清",
|
||||
"鸡胡","癞子胡",
|
||||
"杠上开花","海底捞月","抢杠胡","天胡","地胡",
|
||||
"将","无字" // base_fan:0/1 markers
|
||||
};
|
||||
foreach (var f in rules.FanTypes)
|
||||
{
|
||||
@ -72,10 +77,11 @@ public class CapabilityRegistry
|
||||
warnings.Add($"番型「{f.Name}」(base_fan:{f.BaseFan})引擎无识别逻辑→得分始终为0");
|
||||
}
|
||||
|
||||
// Fan condition strings
|
||||
// Fan condition strings (only warn for ones engine can't evaluate)
|
||||
var knownConditions = new HashSet<string> { "hand_contains_wildcard", "can_win_tumo_and_fan_ge_8", "can_win_and_fan_ge_8" };
|
||||
foreach (var f in rules.FanTypes.Where(f => !string.IsNullOrEmpty(f.Condition)))
|
||||
{
|
||||
if (f.Condition != "hand_contains_wildcard")
|
||||
if (!knownConditions.Contains(f.Condition))
|
||||
warnings.Add($"番型「{f.Name}」条件「{f.Condition}」引擎无法评估→番型永不触发");
|
||||
}
|
||||
|
||||
@ -84,9 +90,8 @@ public class CapabilityRegistry
|
||||
&& rules.WildcardRules.Behavior != "substitute")
|
||||
warnings.Add($"wildcard_rules.behavior={rules.WildcardRules.Behavior} 引擎仅支持substitute→忽略");
|
||||
|
||||
// Fu flag (过水)
|
||||
if (rules.Phases.Any(p => p.FuFlag != null))
|
||||
warnings.Add("过水(fu_flag)机制未实现→吃碰杠后pass仍可胡同一张牌");
|
||||
// Fu flag (过水) — now implemented
|
||||
// No warning needed
|
||||
|
||||
// Pre-hooks
|
||||
var knownHooks = new HashSet<string> { "wildcard_count", "check_hua_zhu", "check_ting" };
|
||||
|
||||
@ -161,6 +161,7 @@ public class MahjongRoom
|
||||
|
||||
// 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
|
||||
@ -223,6 +224,8 @@ public class MahjongRoom
|
||||
int wildInHand = CountWildcardsInHand(fullHand);
|
||||
var result = _solver.CheckWin(fullHand,
|
||||
wildcardCount: wildInHand, require258Pair: r258);
|
||||
if (result != null)
|
||||
result.Fans = _solver.IdentifyFans(result, State); // event-based fans
|
||||
State.HuPlayers.Add(player);
|
||||
State.AlivePlayers.Remove(player);
|
||||
State.AddEvent("win", player, null,
|
||||
@ -249,6 +252,12 @@ public class MahjongRoom
|
||||
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)}");
|
||||
}
|
||||
@ -319,7 +328,10 @@ public class MahjongRoom
|
||||
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))
|
||||
@ -341,6 +353,12 @@ public class MahjongRoom
|
||||
|
||||
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);
|
||||
@ -363,7 +381,8 @@ public class MahjongRoom
|
||||
{
|
||||
return ExecuteChi(p, discardTile);
|
||||
}
|
||||
// Human passed — continue to next player
|
||||
// Human passed — set fu_flag if they could have won
|
||||
if (canWin) State.FuFlags[p] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -555,6 +574,7 @@ public class MahjongRoom
|
||||
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)}");
|
||||
}
|
||||
|
||||
@ -581,6 +601,7 @@ public class MahjongRoom
|
||||
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)
|
||||
{
|
||||
@ -588,6 +609,7 @@ public class MahjongRoom
|
||||
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)}");
|
||||
}
|
||||
|
||||
@ -720,6 +742,10 @@ public class MahjongRoom
|
||||
|
||||
State.AddEvent("deal_done", "", null, "发牌完成");
|
||||
|
||||
// Set first-turn flags for event-based fans
|
||||
State.IsFirstTurn = true;
|
||||
State.DealerOnFirstTurn = State.Dealer;
|
||||
|
||||
// Replace flowers in initial hands BEFORE game starts (e.g. Guangdong 鸡平胡)
|
||||
if (_rules.FlowerRules != null && includeFlowers
|
||||
&& string.Equals(_rules.FlowerRules.ReplaceTiles, "BEFORE_GAME_START", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@ -584,7 +584,7 @@ public class MeldsSolver
|
||||
}
|
||||
|
||||
// === 番型识别 (DSL-driven: structural patterns auto-detected) ===
|
||||
public List<string> IdentifyFans(MeldsResult result)
|
||||
public List<string> IdentifyFans(MeldsResult result, MahjongGameState? state = null)
|
||||
{
|
||||
var fans = new List<string>();
|
||||
var melds = result.Melds;
|
||||
@ -598,7 +598,7 @@ public class MeldsSolver
|
||||
|
||||
if (allTiles.Count == 0) return fans;
|
||||
|
||||
// === Structural pattern recognizers (always evaluated regardless of config) ===
|
||||
// === Structural pattern recognizers ===
|
||||
|
||||
// 清一色: all tiles same suit (numbered suits only)
|
||||
var suits = allTiles.Where(t => MahjongTile.IsNumbered(t))
|
||||
@ -608,22 +608,29 @@ public class MeldsSolver
|
||||
fans.Add("清一色");
|
||||
|
||||
// 混一色: one suit + honors
|
||||
if (suits.Count == 1 && suits[0] < 3 && hasHonors && allTiles.Any(MahjongTile.IsHonor))
|
||||
if (suits.Count == 1 && suits[0] < 3 && hasHonors)
|
||||
fans.Add("混一色");
|
||||
|
||||
// 字一色: all honors
|
||||
if (allTiles.All(MahjongTile.IsHonor))
|
||||
fans.Add("字一色");
|
||||
|
||||
// 对对胡: all melds are kezi
|
||||
// 对对胡 / 碰碰胡 / 碰碰和: all melds are kezi
|
||||
if (melds.Count > 0 && melds.All(m => m.Type == "kezi"))
|
||||
fans.Add(FanName("对对胡", "碰碰胡", "碰碰和"));
|
||||
|
||||
// 暗七对 (all melds are pairs — from seven-pairs path)
|
||||
// 暗七对 / 七对 (from seven-pairs path)
|
||||
if (melds.Count > 0 && melds.All(m => m.Type == "pair"))
|
||||
fans.Add(FanName("暗七对", "七对"));
|
||||
|
||||
// 带幺九: all melds contain 1/9/honor
|
||||
// 将一色: all tiles are 2/5/8
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t)))
|
||||
{
|
||||
bool all258 = allTiles.All(t => { int r = MahjongTile.Rank(t); return r == 2 || r == 5 || r == 8; });
|
||||
if (all258) fans.Add("将一色");
|
||||
}
|
||||
|
||||
// 带幺九: all melds contain 1/9/honor, pair is terminal
|
||||
if (melds.Count > 0 && pair.Count >= 1)
|
||||
{
|
||||
bool allMeldsTerminal = melds.All(m =>
|
||||
@ -633,7 +640,7 @@ public class MeldsSolver
|
||||
fans.Add(FanName("带幺九", "全带幺"));
|
||||
}
|
||||
|
||||
// 混幺九: all tiles are terminals or honors, includes at least one honor and one numbered
|
||||
// 混幺九: all tiles are terminals or honors, has both
|
||||
if (melds.Count > 0 && pair.Count >= 1)
|
||||
{
|
||||
bool allTerminalOrHonor = allTiles.All(t => MahjongTile.IsTerminal(t) || MahjongTile.IsHonor(t));
|
||||
@ -648,59 +655,113 @@ public class MeldsSolver
|
||||
if (numberedSuits <= 2 && allTiles.Any(t => MahjongTile.IsNumbered(t)))
|
||||
fans.Add("缺一门");
|
||||
|
||||
// 平胡 (all shunzi, non-258 pair, non-single-wait)
|
||||
// 平胡 / 平和: all shunzi
|
||||
if (melds.Count > 0 && melds.All(m => m.Type == "shunzi"))
|
||||
fans.Add("平胡");
|
||||
|
||||
// 全带幺 (every meld and pair contains a terminal or honor) — same as 带幺九
|
||||
// Already covered above as 带幺九
|
||||
fans.Add(FanName("平胡", "平和"));
|
||||
|
||||
// 断幺九: no terminals or honors
|
||||
bool noTerminals = allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t));
|
||||
if (noTerminals && allTiles.Count > 0)
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t)))
|
||||
fans.Add("断幺九");
|
||||
|
||||
// 全大 (all ranks 7-9)
|
||||
bool allBig = allTiles.All(t =>
|
||||
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7);
|
||||
if (allBig && allTiles.All(MahjongTile.IsNumbered))
|
||||
// 无字: all tiles are numbered (no honors)
|
||||
if (!hasHonors && allTiles.All(t => MahjongTile.IsNumbered(t)))
|
||||
fans.Add("无字");
|
||||
|
||||
// 全大 (ranks 7-9)
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7))
|
||||
fans.Add("全大");
|
||||
|
||||
// 全中 (all ranks 4-6)
|
||||
bool allMid = allTiles.All(t =>
|
||||
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6);
|
||||
if (allMid && allTiles.All(MahjongTile.IsNumbered))
|
||||
// 全中 (ranks 4-6)
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6))
|
||||
fans.Add("全中");
|
||||
|
||||
// 全小 (all ranks 1-3)
|
||||
bool allSmall = allTiles.All(t =>
|
||||
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3);
|
||||
if (allSmall && allTiles.All(MahjongTile.IsNumbered))
|
||||
// 全小 (ranks 1-3)
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3))
|
||||
fans.Add("全小");
|
||||
|
||||
// 大于五 (all ranks > 5)
|
||||
bool allGreater5 = allTiles.All(t =>
|
||||
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5);
|
||||
if (allGreater5 && allTiles.All(MahjongTile.IsNumbered))
|
||||
// 大于五
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5))
|
||||
fans.Add("大于五");
|
||||
|
||||
// 小于五 (all ranks < 5)
|
||||
bool allLess5 = allTiles.All(t =>
|
||||
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5);
|
||||
if (allLess5 && allTiles.All(MahjongTile.IsNumbered))
|
||||
// 小于五
|
||||
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5))
|
||||
fans.Add("小于五");
|
||||
|
||||
// 全双 (all ranks even)
|
||||
bool allEven = allTiles.All(t =>
|
||||
!MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0);
|
||||
if (allEven && allTiles.All(t => MahjongTile.IsNumbered(t) || MahjongTile.IsHonor(t)))
|
||||
// 全双 (all even)
|
||||
if (allTiles.All(t => !MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0))
|
||||
fans.Add("全双");
|
||||
|
||||
// 大四喜: 4 wind kongs/pungs (东31,南32,西33,北34)
|
||||
var keziTiles = melds.Where(m => m.Type == "kezi").SelectMany(m => m.Tiles).Where(t => t > 0);
|
||||
int windKezi = keziTiles.Where(t => t >= 31 && t <= 34).GroupBy(t => t).Count(g => g.Count() >= 3);
|
||||
if (windKezi == 4) fans.Add("大四喜");
|
||||
|
||||
// 大三元: 3 dragon kongs/pungs (中35,发36,白37)
|
||||
int dragonKezi = keziTiles.Where(t => t >= 35 && t <= 37).GroupBy(t => t).Count(g => g.Count() >= 3);
|
||||
if (dragonKezi == 3) fans.Add("大三元");
|
||||
|
||||
// 小四喜: 3 wind kezi + wind pair
|
||||
var pairTiles = pair.Where(t => t > 0);
|
||||
bool windPair = pairTiles.All(t => t >= 31 && t <= 34) && pairTiles.Distinct().Count() == 1;
|
||||
if (windKezi == 3 && windPair && pairTiles.Any(t => t >= 31 && t <= 34))
|
||||
fans.Add("小四喜");
|
||||
|
||||
// 小三元: 2 dragon kezi + dragon pair
|
||||
bool dragonPair = pairTiles.All(t => t >= 35 && t <= 37) && pairTiles.Distinct().Count() == 1;
|
||||
if (dragonKezi == 2 && dragonPair && pairTiles.Any(t => t >= 35 && t <= 37))
|
||||
fans.Add("小三元");
|
||||
|
||||
// 一色四同顺: four identical sequences in same suit
|
||||
var shunziGroups = melds.Where(m => m.Type == "shunzi")
|
||||
.GroupBy(m => string.Join(",", m.Tiles.OrderBy(t => t)));
|
||||
if (shunziGroups.Any(g => g.Count() >= 4))
|
||||
fans.Add("一色四同顺");
|
||||
|
||||
// 五门齐: all 5 tile categories present (3 suits + honors + at least one terminal)
|
||||
var categories = new HashSet<int>();
|
||||
foreach (var t in allTiles)
|
||||
{
|
||||
if (MahjongTile.IsHonor(t)) categories.Add(3);
|
||||
else categories.Add(MahjongTile.Suit(t));
|
||||
}
|
||||
if (categories.Count >= 4) // 3 suits + honors = 4 categories means 五门齐
|
||||
fans.Add("五门齐");
|
||||
|
||||
// 全求人: all 4 melds exposed (allTiles = pair only, or 2 tiles)
|
||||
if (state != null && melds.Count == 0 && allTiles.Count <= 2)
|
||||
fans.Add("全求人");
|
||||
|
||||
// 门前清: no exposed melds
|
||||
if (state != null)
|
||||
{
|
||||
if (state.Exposed.All(kv => kv.Value.Count == 0))
|
||||
fans.Add("门前清");
|
||||
}
|
||||
|
||||
// 癞子胡: hand contains wildcard (condition from DSL)
|
||||
if (state != null && state.Wildcards.CountWildcards(allTiles) > 0)
|
||||
fans.Add("癞子胡");
|
||||
|
||||
// Event-based fans (require state)
|
||||
if (state != null)
|
||||
{
|
||||
if (state.IsKongDraw) fans.Add("杠上开花");
|
||||
if (state.IsLastTile) fans.Add("海底捞月");
|
||||
if (state.IsRobbedKong) fans.Add("抢杠胡");
|
||||
if (state.IsFirstTurn && state.DealerOnFirstTurn != null)
|
||||
fans.Add("天胡");
|
||||
else if (state.IsFirstTurn)
|
||||
fans.Add("地胡");
|
||||
}
|
||||
|
||||
// 鸡胡: base-level fan when no other structural fan matches
|
||||
if (fans.Count == 0)
|
||||
fans.Add("鸡胡");
|
||||
|
||||
// Filter: only keep fans that exist in DSL config or are well-known
|
||||
return fans.Where(f => _fanConfig.ContainsKey(f) ||
|
||||
f is "清一色" or "混一色" or "对对胡" or "碰碰和" or "暗七对" or "七对"
|
||||
or "带幺九" or "十三幺" or "字一色" or "断幺九"
|
||||
or "缺一门" or "平胡" or "全大" or "全中" or "全小" or "全双").ToList();
|
||||
or "带幺九" or "十三幺" or "字一色" or "断幺九" or "无字" or "将"
|
||||
or "缺一门" or "平胡" or "全大" or "全中" or "全小" or "全双"
|
||||
or "将一色" or "大四喜" or "大三元" or "小四喜" or "小三元"
|
||||
or "一色四同顺" or "五门齐" or "全求人" or "门前清"
|
||||
or "鸡胡" or "癞子胡" or "平和"
|
||||
or "杠上开花" or "海底捞月" or "抢杠胡" or "天胡" or "地胡").ToList();
|
||||
}
|
||||
|
||||
// === 番型互斥应用 ===
|
||||
|
||||
Reference in New Issue
Block a user