Files
card-game-engine/RuleEngine/Dsl/DslLoader.cs
xiaoou 7836d4f889 feat: --check全面报告引擎不支持的游戏规则
新增8类规则缺口检查(引擎功能边界):
1. on_eliminate未消费→淘汰逻辑基本实现
2. on_win未消费→胡牌终止默认行为
3. fu_flag硬编码过水→DSL值不生效
4. 花牌+deal action不一致→初始花牌不补
5. flower_rules.on_draw≠replace_and_draw→不支持
6. 玩家数≠4→引擎仅支持4人
7. draw.on_draw_flower/on_empty_deck≠引擎默认→硬编码
8. fan_type condition≠hand_contains_wildcard→不支持

效果: 南昌4/武汉2/国标1/血战3/广东1条警告
全部指向真实的引擎功能边界,用户可据此判断DSL规则是否能完整执行
2026-07-05 00:07:23 +08:00

423 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace RuleEngine.Dsl;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
public class CapabilityRegistry
{
private readonly HashSet<string> _capabilities = new();
// All known engine capabilities (what the engine CAN do)
private static readonly HashSet<string> KnownCapabilities = new()
{
"meldsolver.standard_win",
"meldsolver.seven_pairs",
"meldsolver.thirteen_orphans",
"meldsolver.all_orphans",
"meldsolver.double_dragon",
"meldsolver.wildcard",
"deck.flower_cards",
"deck.generator_mahjong",
"phase.mahjong_turn",
"phase.parallel_elimination",
"phase.priority_arbitration",
"scoring.fan_exclusion",
"scoring.pre_hooks",
};
public void Register(string id) => _capabilities.Add(id);
public bool Has(string id) => _capabilities.Contains(id);
public void Check(IEnumerable<string> required)
{
var missing = required.Where(r => !_capabilities.Contains(r)).ToList();
if (missing.Count > 0)
{
string list = string.Join("\n ", missing.Select(m =>
$"❌ {m} — 引擎尚未实现"));
int matched = required.Count() - missing.Count;
throw new InvalidOperationException(
$"DSL 需要的以下算法能力引擎尚未实现 ({matched}/{required.Count()} 匹配):\n {list}\n\n" +
$"请添加对应实现后注册 Capability。\n" +
$"当前引擎能力: {string.Join(", ", _capabilities)}");
}
}
/// <summary>Compute how many DSL-required capabilities the engine has registered.</summary>
public (int required, int matched, int percent) ComputeCoverage(IEnumerable<string> required)
{
int r = required.Count();
if (r == 0) return (0, 0, 100);
int m = required.Count(req => _capabilities.Contains(req));
return (r, m, m * 100 / r);
}
/// <summary>Comprehensive rule-level compliance check — finds DSL rules the engine can't fully honor.</summary>
public List<string> ValidateRuleCompliance(MahjongDslRoot rules)
{
var warnings = new List<string>();
// Fan types: check engine can recognize (structural patterns)
var enginePatterns = new HashSet<string>
{
"清一色","混一色","字一色","对对胡","碰碰胡","碰碰和","暗七对","七对",
"带幺九","全带幺","混幺九","缺一门","平胡","平和","断幺九",
"全大","全中","全小","全双","大于五","小于五",
"十三幺","全不靠","一色双龙会","连七对",
"将一色","大四喜","大三元","小四喜","小三元","三风","双箭刻",
"一色四同顺","一色三同顺","一般高","五门齐","全求人","门前清",
"鸡胡","癞子胡",
"圈风","门风","单钓将","四归一","杠上开花","海底捞月","抢杠胡","天胡","地胡",
"将","无字" // base_fan:0/1 markers
};
foreach (var f in rules.FanTypes)
{
if (!enginePatterns.Contains(f.Name) && !string.IsNullOrEmpty(f.Name))
warnings.Add($"番型「{f.Name}」(base_fan:{f.BaseFan})引擎无识别逻辑→得分始终为0");
}
// 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 (!knownConditions.Contains(f.Condition!))
warnings.Add($"番型「{f.Name}」条件「{f.Condition}」引擎无法评估→番型永不触发");
}
// Wildcard behavior
if (rules.WildcardRules != null && !string.IsNullOrEmpty(rules.WildcardRules.Behavior)
&& rules.WildcardRules.Behavior != "substitute")
warnings.Add($"wildcard_rules.behavior={rules.WildcardRules.Behavior} 引擎仅支持substitute→忽略");
// Wildcard type: random is now supported
if (rules.WildcardRules != null && !string.IsNullOrEmpty(rules.WildcardRules.Type))
{
var validTypes = new HashSet<string> { "fixed", "random" };
if (!validTypes.Contains(rules.WildcardRules.Type))
warnings.Add($"wildcard_rules.type={rules.WildcardRules.Type} 引擎仅支持fixed/random→忽略");
}
// Fu flag (过水) — now implemented
// No warning needed
// Pre-hooks
var knownHooks = new HashSet<string> { "wildcard_count", "check_hua_zhu", "check_ting" };
foreach (var h in rules.PreHooks.Concat(rules.Scoring.PreHooks))
{
if (!string.IsNullOrEmpty(h.Name) && !knownHooks.Contains(h.Name))
warnings.Add($"pre_hook「{h.Name}」引擎未实现→{h.Description}");
}
// fan_stacking 值验证
var validStackings = new HashSet<string> { "add", "add_max", "max_level" };
if (!validStackings.Contains(rules.FanStacking))
warnings.Add($"fan_stacking={rules.FanStacking} 非法→仅支持 add/add_max/max_level→已按add计算");
// Scoring mode
if (rules.Scoring.Mode != "fan_table")
warnings.Add($"scoring.mode={rules.Scoring.Mode} 引擎仅支持fan_table→忽略");
// 鸡胡 兜底番型: DSL应配置以避免FanValue=0
if (!rules.FanTypes.Any(f => f.Name == "鸡胡"))
warnings.Add("fan_types 缺少鸡胡兜底番型→建议添加 { name: 鸡胡, base_fan: 1 }");
// 番型值检查: base_fan=0 的标记番型需有更高番型覆盖
var zeroFans = rules.FanTypes.Where(f => f.BaseFan <= 0).ToList();
if (zeroFans.Count > 0)
warnings.Add($"有 {zeroFans.Count} 个番型 base_fan=0 ({string.Join(", ", zeroFans.Select(f => f.Name))})→仅当被更高番型排除时有用");
// 番型识别率: DSL 中多少番型引擎能识别
int recognized = rules.FanTypes.Count(f => enginePatterns.Contains(f.Name));
if (recognized < rules.FanTypes.Count)
warnings.Add($"番型识别率: {recognized}/{rules.FanTypes.Count}→{rules.FanTypes.Count - recognized} 个番型得分为0");
// Excludes/Conflicts 引用完整性
// excludes: 用字符串匹配(不依赖_fanConfig), 只检查引擎是否可能生成该番型名
// conflicts: 需要双方都在_fanConfig中才能比较BaseFan
var fanNames = rules.FanTypes.Select(f => f.Name).ToHashSet();
foreach (var f in rules.FanTypes)
{
if (f.Excludes != null)
foreach (var excluded in f.Excludes)
if (!enginePatterns.Contains(excluded)) // excludes work on string names, check engine can generate
warnings.Add($"番型「{f.Name}」excludes 引用了引擎不识别的番型「{excluded}」→排除规则无效(引擎不生成此名)");
if (f.Conflicts != null)
foreach (var conflict in f.Conflicts)
if (!fanNames.Contains(conflict)) // conflicts need _fanConfig for comparison
warnings.Add($"番型「{f.Name}」conflicts 引用了不存在的番型「{conflict}」→冲突规则失效");
}
// max_fan 合理性
if (rules.MaxFan < 0)
warnings.Add($"max_fan={rules.MaxFan} 非法→应为正数或0");
// Phase type 验证
var validPhaseTypes = new HashSet<string> { "auto", "mahjong_turn" };
foreach (var p in rules.Phases)
if (!validPhaseTypes.Contains(p.Type))
warnings.Add($"phase「{p.Name}」type={p.Type} 非法→仅支持 auto/mahjong_turn");
// EndCondition type 验证
var validEndTypes = new HashSet<string> { "player_wins", "deck_exhausted", "last_one_standing" };
foreach (var p in rules.Phases)
if (p.EndConditions != null)
foreach (var ec in p.EndConditions)
if (!validEndTypes.Contains(ec.Type))
warnings.Add($"phase「{p.Name}」end_condition type={ec.Type} 非法→仅支持 player_wins/deck_exhausted/last_one_standing");
// Action 名验证
var validActions = new HashSet<string> { "discard", "an_kong", "bu_kong", "win", "chi", "pung", "ming_kong", "pass" };
foreach (var p in rules.Phases)
{
var allActions = new List<ActionOptionDsl>();
if (p.SubPhases?.SelfAction?.Options != null) allActions.AddRange(p.SubPhases.SelfAction.Options);
if (p.SubPhases?.OthersReaction?.Options != null) allActions.AddRange(p.SubPhases.OthersReaction.Options);
foreach (var a in allActions)
if (!validActions.Contains(a.Action))
warnings.Add($"phase「{p.Name}」action={a.Action} 非法→引擎忽略");
}
// PriorityPolicy 验证
var validPolicies = new HashSet<string> { "highest_wins" };
foreach (var p in rules.Phases)
if (p.SubPhases?.OthersReaction?.PriorityPolicy != null
&& !validPolicies.Contains(p.SubPhases.OthersReaction.PriorityPolicy))
warnings.Add($"priority_policy={p.SubPhases.OthersReaction.PriorityPolicy} 非法→仅支持 highest_wins");
// FanCalculationPolicy 未消费警告
if (rules.WildcardRules != null && !string.IsNullOrEmpty(rules.WildcardRules.FanCalculationPolicy)
&& rules.WildcardRules.FanCalculationPolicy != "optimal")
warnings.Add($"wildcard_rules.fan_calculation_policy={rules.WildcardRules.FanCalculationPolicy} 引擎未实现→仅optimal生效");
// 精牌计数限制: 引擎仅统计手牌中的精, 不含副露
if (rules.WildcardRules?.Scoring?.PerWildcardInWin > 0)
warnings.Add("精计数仅含手牌(不含副露)→冲关/精数量倍增未支持");
// 冲关: 精数量×2^N 倍增计分未实现
if (rules.WildcardRules?.Type == "random")
warnings.Add("骰子翻牌定精: 冲关(精数×2^N)/德国(无精胡)计分规则引擎未实现");
// === 引擎功能边界警告: DSL可配置但引擎不支持或部分支持 ===
// 未消费字段
if (rules.Phases.Any(p => !string.IsNullOrEmpty(p.OnEliminate)))
warnings.Add("phase.on_eliminate 已配置但引擎未消费→淘汰逻辑为基本实现");
if (rules.Phases.Any(p => !string.IsNullOrEmpty(p.SubPhases?.OthersReaction?.OnWin)))
warnings.Add("phase.on_win 已配置但引擎未消费→胡牌终止为默认行为");
if (rules.Phases.Any(p => p.FuFlag != null))
warnings.Add("phase.fu_flag 已配置但引擎用硬编码过水→DSL值不生效");
// 花牌一致性
var dealPhase = rules.Phases.FirstOrDefault(p => p.Name == "deal");
if (rules.FlowerRules != null && dealPhase?.Action != "deal_cards_with_flowers")
warnings.Add("有flower_rules但deal action非deal_cards_with_flowers→初始花牌不补");
if (rules.FlowerRules != null && !string.IsNullOrEmpty(rules.FlowerRules.OnDraw)
&& rules.FlowerRules.OnDraw != "replace_and_draw")
warnings.Add($"flower_rules.on_draw={rules.FlowerRules.OnDraw}→引擎仅支持replace_and_draw");
// 玩家数
if ((rules.Game.Players?.Min ?? 4) != 4 || (rules.Game.Players?.Max ?? 4) != 4)
warnings.Add("玩家数≠4→引擎仅支持4人");
// Draw sub-phase 引擎硬编码
foreach (var p in rules.Phases)
{
var odf = p.SubPhases?.Draw?.OnDrawFlower;
if (!string.IsNullOrEmpty(odf) && odf != "replace")
warnings.Add($"phase「{p.Name}」draw.on_draw_flower={odf}→引擎仅支持replace");
var oe = p.SubPhases?.Draw?.OnEmptyDeck;
if (!string.IsNullOrEmpty(oe) && oe != "exhausted" && !rules.Phases.Any(ph => ph.ParallelElimination))
warnings.Add($"phase「{p.Name}」draw.on_empty_deck={oe}→引擎硬编码处理(血战除外)");
}
// Fan condition: 仅 hand_contains_wildcard 被引擎求值
foreach (var f in rules.FanTypes.Where(f => !string.IsNullOrEmpty(f.Condition)))
if (f.Condition != "hand_contains_wildcard")
warnings.Add($"番型「{f.Name}」condition={f.Condition}→引擎仅支持hand_contains_wildcard");
return warnings;
}
}
// === DSL POCO types ===
public class MahjongDslRoot
{
public GameInfo Game { get; set; } = new();
public List<string> Requires { get; set; } = new();
public DeckConfig Deck { get; set; } = new();
public DealConfig Deal { get; set; } = new();
public WildcardConfig? WildcardRules { get; set; }
public WinConditionConfig? WinCondition { get; set; }
public WinRuleConfig? WinRule { get; set; }
public List<PreHookConfig> PreHooks { get; set; } = new();
public List<FanTypeConfig> FanTypes { get; set; } = new();
public string FanStacking { get; set; } = "add";
public int MaxFan { get; set; } = int.MaxValue;
public List<PhaseDslConfig> Phases { get; set; } = new();
public ScoringDslConfig Scoring { get; set; } = new();
public int WinMinFan { get; set; } = 0;
public FlowerDslConfig? FlowerRules { get; set; }
}
public class GameInfo
{
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public string EngineType { get; set; } = "";
public PlayersConfig? Players { get; set; }
}
public class PlayersConfig { public int Min { get; set; } public int Max { get; set; } }
public class DeckConfig
{
public string Generator { get; set; } = "mahjong";
public int Total { get; set; }
public bool IncludeHonors { get; set; }
public bool IncludeFlowers { get; set; }
public int WildcardCount { get; set; }
public string? WildcardTile { get; set; }
}
public class DealConfig { public int CardsPerPlayer { get; set; } = 13; public int DealerExtra { get; set; } = 1; }
public class WildcardConfig
{
public string Type { get; set; } = "";
public string Behavior { get; set; } = "";
public int WildcardEncoding { get; set; } = 50;
public int RandomCount { get; set; } = 2; // random type: how many next-tiles are 精 (1=正精 only, 2=正精+副精)
public List<string>? Tiles { get; set; }
public WildcardScoringConfig? Scoring { get; set; }
public string FanCalculationPolicy { get; set; } = "optimal";
}
public class WildcardScoringConfig { public int PerWildcardInWin { get; set; } }
public class WinConditionConfig
{
[YamlMember(Alias = "pair_must_be_258")]
public bool PairMustBe258 { get; set; }
}
public class WinRuleConfig { public bool JiHuSelfDrawOnly { get; set; } }
public class PreHookConfig
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Condition { get; set; } = "";
}
public class FanTypeConfig
{
public string Name { get; set; } = "";
public int BaseFan { get; set; }
public int Level { get; set; }
public List<string>? Excludes { get; set; }
public List<string>? Conflicts { get; set; }
public string? Condition { get; set; }
}
public class PhaseDslConfig
{
public string Name { get; set; } = "";
public string Type { get; set; } = "";
public string? Action { get; set; }
public string? Next { get; set; }
public SubPhasesConfig? SubPhases { get; set; }
public List<EndConditionDsl>? EndConditions { get; set; }
public bool ParallelElimination { get; set; }
public string? OnEliminate { get; set; }
public FuFlagConfig? FuFlag { get; set; }
public string TurnOrder { get; set; } = "counter_clockwise";
}
public class FuFlagConfig
{
public string Type { get; set; } = "";
public string SetOn { get; set; } = "";
public string ClearOn { get; set; } = "";
public string Effect { get; set; } = "";
}
public class SubPhasesConfig
{
public DrawSubPhase? Draw { get; set; }
public SelfActionSubPhase? SelfAction { get; set; }
public OthersReactionSubPhase? OthersReaction { get; set; }
}
public class DrawSubPhase
{
public string Type { get; set; } = "auto";
public string Action { get; set; } = "draw_card";
public string OnDrawFlower { get; set; } = "";
public string OnEmptyDeck { get; set; } = "";
}
public class SelfActionSubPhase { public List<ActionOptionDsl> Options { get; set; } = new(); }
public class OthersReactionSubPhase
{
public List<ActionOptionDsl> Options { get; set; } = new();
public string PriorityPolicy { get; set; } = "highest_wins";
public string? OnWin { get; set; }
}
public class ActionOptionDsl { public string Action { get; set; } = ""; public int Priority { get; set; } public string? Condition { get; set; } }
public class EndConditionDsl { public string Type { get; set; } = ""; public string Action { get; set; } = ""; }
public class ScoringDslConfig
{
public string Mode { get; set; } = "fan_table";
public int MaxCap { get; set; } = int.MaxValue;
public List<PreHookConfig> PreHooks { get; set; } = new();
public int SelfDrawMultiplier { get; set; } = 1; // 自摸: 每家付 baseFan × N
public int DiscardWinMultiplier { get; set; } = 3; // 点炮: 放炮者付 baseFan × N (默认3=传统包赔)
public int DealerMultiplier { get; set; } = 1; // 庄家: 庄家输赢 × N
}
public class FlowerDslConfig
{
public string OnDraw { get; set; } = "";
public string ReplaceTiles { get; set; } = "";
public FlowerScoringConfig? Scoring { get; set; }
}
public class FlowerScoringConfig
{
public int Normal { get; set; }
public FlowerMatchingConfig? Matching { get; set; }
}
public class FlowerMatchingConfig
{
public int Value { get; set; }
public Dictionary<int, List<string>>? Mapping { get; set; }
}
public class DslLoader
{
private readonly CapabilityRegistry _caps;
private readonly IDeserializer _deserializer;
public DslLoader(CapabilityRegistry caps)
{
_caps = caps;
_deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.Build(); // No more IgnoreUnmatchedProperties — catch all unknown fields
}
public MahjongDslRoot Load(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"DSL file not found: {path}");
var yaml = File.ReadAllText(path);
return LoadString(yaml, path);
}
public MahjongDslRoot LoadString(string yaml, string source = "inline")
{
MahjongDslRoot dsl;
try
{
dsl = _deserializer.Deserialize<MahjongDslRoot>(yaml)
?? throw new InvalidOperationException($"Failed to parse DSL: {source}");
}
catch (YamlDotNet.Core.YamlException ex) when (ex.Message.Contains("not found"))
{
throw new InvalidOperationException(
$"DSL 文件包含引擎不认识的字段。请检查 YAML 键名是否与引擎定义一致。\n{ex.Message}");
}
// Check capabilities
_caps.Check(dsl.Requires);
return dsl;
}
}