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:
xiaoou
2026-07-04 20:45:36 +08:00
parent 237a6dc0ad
commit 721333390a
4 changed files with 158 additions and 54 deletions

View File

@ -35,6 +35,13 @@ public class MahjongGameState
public int? LastDrawnTile { get; set; } public int? LastDrawnTile { get; set; }
public WildcardRegistry Wildcards { get; set; } = new(); 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() public MahjongGameState Clone()
{ {
return new MahjongGameState return new MahjongGameState
@ -59,7 +66,12 @@ public class MahjongGameState
RecentEvents = new List<GameEvent>(RecentEvents), RecentEvents = new List<GameEvent>(RecentEvents),
FlowerReplaced = FlowerReplaced, FlowerReplaced = FlowerReplaced,
LastDrawnTile = LastDrawnTile, LastDrawnTile = LastDrawnTile,
Wildcards = Wildcards.Clone() Wildcards = Wildcards.Clone(),
IsKongDraw = IsKongDraw,
IsLastTile = IsLastTile,
IsRobbedKong = IsRobbedKong,
IsFirstTurn = IsFirstTurn,
DealerOnFirstTurn = DealerOnFirstTurn
}; };
} }

View File

@ -62,9 +62,14 @@ public class CapabilityRegistry
var enginePatterns = new HashSet<string> var enginePatterns = new HashSet<string>
{ {
"清一色","混一色","字一色","对对胡","碰碰胡","碰碰和","暗七对","七对", "清一色","混一色","字一色","对对胡","碰碰胡","碰碰和","暗七对","七对",
"带幺九","全带幺","混幺九","缺一门","平胡","断幺九", "带幺九","全带幺","混幺九","缺一门","平胡","平和","断幺九",
"全大","全中","全小","全双","大于五","小于五", "全大","全中","全小","全双","大于五","小于五",
"十三幺","全不靠","一色双龙会" // special patterns from CheckWin "十三幺","全不靠","一色双龙会","连七对",
"将一色","大四喜","大三元","小四喜","小三元",
"一色四同顺","五门齐","全求人","门前清",
"鸡胡","癞子胡",
"杠上开花","海底捞月","抢杠胡","天胡","地胡",
"将","无字" // base_fan:0/1 markers
}; };
foreach (var f in rules.FanTypes) foreach (var f in rules.FanTypes)
{ {
@ -72,10 +77,11 @@ public class CapabilityRegistry
warnings.Add($"番型「{f.Name}」(base_fan:{f.BaseFan})引擎无识别逻辑→得分始终为0"); 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))) 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}」引擎无法评估→番型永不触发"); warnings.Add($"番型「{f.Name}」条件「{f.Condition}」引擎无法评估→番型永不触发");
} }
@ -84,9 +90,8 @@ public class CapabilityRegistry
&& rules.WildcardRules.Behavior != "substitute") && rules.WildcardRules.Behavior != "substitute")
warnings.Add($"wildcard_rules.behavior={rules.WildcardRules.Behavior} 引擎仅支持substitute→忽略"); warnings.Add($"wildcard_rules.behavior={rules.WildcardRules.Behavior} 引擎仅支持substitute→忽略");
// Fu flag (过水) // Fu flag (过水) — now implemented
if (rules.Phases.Any(p => p.FuFlag != null)) // No warning needed
warnings.Add("过水(fu_flag)机制未实现→吃碰杠后pass仍可胡同一张牌");
// Pre-hooks // Pre-hooks
var knownHooks = new HashSet<string> { "wildcard_count", "check_hua_zhu", "check_ting" }; var knownHooks = new HashSet<string> { "wildcard_count", "check_hua_zhu", "check_ting" };

View File

@ -161,6 +161,7 @@ public class MahjongRoom
// Phase 2: Draw card (skip for dealer on first turn — they already have 14) // Phase 2: Draw card (skip for dealer on first turn — they already have 14)
bool isDealerFirstTurn = (player == State.Dealer && State.RoundNumber == 0); bool isDealerFirstTurn = (player == State.Dealer && State.RoundNumber == 0);
State.IsLastTile = State.Deck.Count == 1; // 海底: deck has exactly 1 tile left
if (isDealerFirstTurn) if (isDealerFirstTurn)
State.LastDrawnTile = null; State.LastDrawnTile = null;
else else
@ -223,6 +224,8 @@ public class MahjongRoom
int wildInHand = CountWildcardsInHand(fullHand); int wildInHand = CountWildcardsInHand(fullHand);
var result = _solver.CheckWin(fullHand, var result = _solver.CheckWin(fullHand,
wildcardCount: wildInHand, require258Pair: r258); wildcardCount: wildInHand, require258Pair: r258);
if (result != null)
result.Fans = _solver.IdentifyFans(result, State); // event-based fans
State.HuPlayers.Add(player); State.HuPlayers.Add(player);
State.AlivePlayers.Remove(player); State.AlivePlayers.Remove(player);
State.AddEvent("win", player, null, State.AddEvent("win", player, null,
@ -249,6 +252,12 @@ public class MahjongRoom
State.LastDiscard = tile.Value; State.LastDiscard = tile.Value;
State.LastDiscardPlayer = player; State.LastDiscardPlayer = player;
RecordDiscard(player, tile.Value); 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, State.AddEvent("discard", player, tile.Value,
$"出牌: {MahjongTile.ToString(tile.Value)}"); $"出牌: {MahjongTile.ToString(tile.Value)}");
} }
@ -319,7 +328,10 @@ public class MahjongRoom
var winResult = _solver.CheckWin(fullHand, var winResult = _solver.CheckWin(fullHand,
wildcardCount: wildInHand, require258Pair: r258); wildcardCount: wildInHand, require258Pair: r258);
if (winResult != null && winResult.IsWin) if (winResult != null && winResult.IsWin)
{
winResult.Fans = _solver.IdentifyFans(winResult, State); // event-based fans
canWin = true; canWin = true;
}
// Human player: ask what they want to do // Human player: ask what they want to do
if (_human != null && p == _human.Name && (canWin || canKong || canPung || canChi)) if (_human != null && p == _human.Name && (canWin || canKong || canPung || canChi))
@ -341,6 +353,12 @@ public class MahjongRoom
if (input == "h" && canWin) 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.DiscardPool.Remove(discardTile);
State.HuPlayers.Add(p); State.HuPlayers.Add(p);
State.AlivePlayers.Remove(p); State.AlivePlayers.Remove(p);
@ -363,7 +381,8 @@ public class MahjongRoom
{ {
return ExecuteChi(p, discardTile); 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; continue;
} }
@ -555,6 +574,7 @@ public class MahjongRoom
State.Deck.RemoveAt(State.Deck.Count - 1); State.Deck.RemoveAt(State.Deck.Count - 1);
State.Hands[player].Add(drawn); State.Hands[player].Add(drawn);
State.LastDrawnTile = drawn; State.LastDrawnTile = drawn;
State.IsKongDraw = true; // 杠后摸牌标志
State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}");
} }
@ -581,6 +601,7 @@ public class MahjongRoom
pung.Type = "kong_bu"; pung.Type = "kong_bu";
pung.Tiles.Add(tile); pung.Tiles.Add(tile);
State.AddEvent("bu_kong", player, tile, $"加杠!{MahjongTile.ToString(tile)}"); State.AddEvent("bu_kong", player, tile, $"加杠!{MahjongTile.ToString(tile)}");
State.IsRobbedKong = true; // 抢杠标志
if (State.Deck.Count > 0) if (State.Deck.Count > 0)
{ {
@ -588,6 +609,7 @@ public class MahjongRoom
State.Deck.RemoveAt(State.Deck.Count - 1); State.Deck.RemoveAt(State.Deck.Count - 1);
State.Hands[player].Add(drawn); State.Hands[player].Add(drawn);
State.LastDrawnTile = drawn; State.LastDrawnTile = drawn;
State.IsKongDraw = true;
State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}"); State.AddEvent("draw", player, drawn, $"杠补: {MahjongTile.ToString(drawn)}");
} }
@ -720,6 +742,10 @@ public class MahjongRoom
State.AddEvent("deal_done", "", null, "发牌完成"); 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 鸡平胡) // Replace flowers in initial hands BEFORE game starts (e.g. Guangdong 鸡平胡)
if (_rules.FlowerRules != null && includeFlowers if (_rules.FlowerRules != null && includeFlowers
&& string.Equals(_rules.FlowerRules.ReplaceTiles, "BEFORE_GAME_START", StringComparison.OrdinalIgnoreCase)) && string.Equals(_rules.FlowerRules.ReplaceTiles, "BEFORE_GAME_START", StringComparison.OrdinalIgnoreCase))

View File

@ -584,7 +584,7 @@ public class MeldsSolver
} }
// === 番型识别 (DSL-driven: structural patterns auto-detected) === // === 番型识别 (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 fans = new List<string>();
var melds = result.Melds; var melds = result.Melds;
@ -598,7 +598,7 @@ public class MeldsSolver
if (allTiles.Count == 0) return fans; if (allTiles.Count == 0) return fans;
// === Structural pattern recognizers (always evaluated regardless of config) === // === Structural pattern recognizers ===
// 清一色: all tiles same suit (numbered suits only) // 清一色: all tiles same suit (numbered suits only)
var suits = allTiles.Where(t => MahjongTile.IsNumbered(t)) var suits = allTiles.Where(t => MahjongTile.IsNumbered(t))
@ -608,22 +608,29 @@ public class MeldsSolver
fans.Add("清一色"); fans.Add("清一色");
// 混一色: one suit + honors // 混一色: 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("混一色"); fans.Add("混一色");
// 字一色: all honors // 字一色: all honors
if (allTiles.All(MahjongTile.IsHonor)) if (allTiles.All(MahjongTile.IsHonor))
fans.Add("字一色"); fans.Add("字一色");
// 对对胡: all melds are kezi // 对对胡 / 碰碰胡 / 碰碰和: all melds are kezi
if (melds.Count > 0 && melds.All(m => m.Type == "kezi")) if (melds.Count > 0 && melds.All(m => m.Type == "kezi"))
fans.Add(FanName("对对胡", "碰碰胡", "碰碰和")); 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")) if (melds.Count > 0 && melds.All(m => m.Type == "pair"))
fans.Add(FanName("暗七对", "七对")); 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) if (melds.Count > 0 && pair.Count >= 1)
{ {
bool allMeldsTerminal = melds.All(m => bool allMeldsTerminal = melds.All(m =>
@ -633,7 +640,7 @@ public class MeldsSolver
fans.Add(FanName("带幺九", "全带幺")); 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) if (melds.Count > 0 && pair.Count >= 1)
{ {
bool allTerminalOrHonor = allTiles.All(t => MahjongTile.IsTerminal(t) || MahjongTile.IsHonor(t)); 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))) if (numberedSuits <= 2 && allTiles.Any(t => MahjongTile.IsNumbered(t)))
fans.Add("缺一门"); fans.Add("缺一门");
// 平胡 (all shunzi, non-258 pair, non-single-wait) // 平胡 / 平和: all shunzi
if (melds.Count > 0 && melds.All(m => m.Type == "shunzi")) if (melds.Count > 0 && melds.All(m => m.Type == "shunzi"))
fans.Add("平胡"); fans.Add(FanName("平胡", "平和"));
// 全带幺 (every meld and pair contains a terminal or honor) — same as 带幺九
// Already covered above as 带幺九
// 断幺九: no terminals or honors // 断幺九: no terminals or honors
bool noTerminals = allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t)); if (allTiles.All(t => MahjongTile.IsNumbered(t) && !MahjongTile.IsTerminal(t)))
if (noTerminals && allTiles.Count > 0)
fans.Add("断幺九"); fans.Add("断幺九");
// 全大 (all ranks 7-9) // 无字: all tiles are numbered (no honors)
bool allBig = allTiles.All(t => if (!hasHonors && allTiles.All(t => MahjongTile.IsNumbered(t)))
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7); fans.Add("无字");
if (allBig && allTiles.All(MahjongTile.IsNumbered))
// 全大 (ranks 7-9)
if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 7))
fans.Add("全大"); fans.Add("全大");
// 全中 (ranks 4-6)
// 全中 (all ranks 4-6) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6))
bool allMid = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) >= 4 && MahjongTile.Rank(t) <= 6);
if (allMid && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全中"); fans.Add("全中");
// 全小 (ranks 1-3)
// 全小 (all ranks 1-3) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3))
bool allSmall = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) <= 3);
if (allSmall && allTiles.All(MahjongTile.IsNumbered))
fans.Add("全小"); fans.Add("全小");
// 大于五
// 大于五 (all ranks > 5) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5))
bool allGreater5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) > 5);
if (allGreater5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("大于五"); fans.Add("大于五");
// 小于五
// 小于五 (all ranks < 5) if (allTiles.All(t => MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5))
bool allLess5 = allTiles.All(t =>
MahjongTile.IsNumbered(t) && MahjongTile.Rank(t) < 5);
if (allLess5 && allTiles.All(MahjongTile.IsNumbered))
fans.Add("小于五"); fans.Add("小于五");
// 全双 (all even)
// 全双 (all ranks even) if (allTiles.All(t => !MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0))
bool allEven = allTiles.All(t =>
!MahjongTile.IsNumbered(t) || MahjongTile.Rank(t) % 2 == 0);
if (allEven && allTiles.All(t => MahjongTile.IsNumbered(t) || MahjongTile.IsHonor(t)))
fans.Add("全双"); 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 // Filter: only keep fans that exist in DSL config or are well-known
return fans.Where(f => _fanConfig.ContainsKey(f) || return fans.Where(f => _fanConfig.ContainsKey(f) ||
f is "清一色" or "混一色" or "对对胡" or "碰碰和" or "暗七对" or "七对" f is "清一色" or "混一色" or "对对胡" or "碰碰和" or "暗七对" or "七对"
or "带幺九" 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 "地胡").ToList();
} }
// === 番型互斥应用 === // === 番型互斥应用 ===