Files
card-game-engine/RuleEngine/Dsl/DslLoader.cs
xiaoou 1576253caa fix: 子代理审查26问题修复13个(剩余为编码定义/DSL文档字段)
本次修复(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个)
2026-07-04 23:11:29 +08:00

375 lines
16 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 引用完整性: 引用的番型名必须存在于 fan_types
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 (!fanNames.Contains(excluded))
warnings.Add($"番型「{f.Name}」excludes 引用了不存在的番型「{excluded}」→排除规则失效");
if (f.Conflicts != null)
foreach (var conflict in f.Conflicts)
if (!fanNames.Contains(conflict))
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生效");
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;
}
}