Files
card-game-engine/RuleEngine/Dsl/DslLoader.cs
xiaoou ce3bbc7760 fix: 精计数警告拆分,冲关/德国仅type=random时报告
修复前: per_wildcard>0→统一显示"冲关/精数量倍增未支持"(武汉误报)
修复后:
- 通用: per_wildcard>0→"精计数仅含手牌(不含副露)"(所有有精玩法)
- 特定: type=random→"冲关/德国未支持"(仅南昌麻将骰子翻牌定精)

警告现在按玩法类型精准匹配,不再跨玩法误报
2026-07-05 00:13:57 +08:00

442 lines
20 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("精计数仅含手牌(不含副露)→per_wildcard_bonus计算范围受限");
// 冲关/德国: 仅南昌麻将骰子翻牌定精相关
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");
// Deck generator
if (rules.Deck.Generator != "mahjong")
warnings.Add($"deck.generator={rules.Deck.Generator}→引擎仅支持mahjong");
// Flower replace_tiles: warning about partial support
if (rules.FlowerRules != null && !string.IsNullOrEmpty(rules.FlowerRules.ReplaceTiles)
&& rules.FlowerRules.ReplaceTiles != "BEFORE_GAME_START")
warnings.Add($"flower_rules.replace_tiles={rules.FlowerRules.ReplaceTiles}→仅BEFORE_GAME_START完全支持");
// Condition strings: can_win*_and_fan_ge_8 is known but fanSum=0 in CheckCondition
if (rules.Phases.Any(p =>
(p.SubPhases?.SelfAction?.Options?.Any(o => o.Condition == "can_win_tumo_and_fan_ge_8") == true)
|| (p.SubPhases?.OthersReaction?.Options?.Any(o => o.Condition == "can_win_and_fan_ge_8") == true)))
warnings.Add("win condition can_win*_and_fan_ge_8 引擎用硬编码检查替代→DSL condition不生效");
// Scoring pre_hooks: conditions not evaluated
if (rules.Scoring.PreHooks.Any(h => !string.IsNullOrEmpty(h.Condition)))
warnings.Add("scoring.pre_hooks 的 condition 字段引擎不评估→条件逻辑为硬编码");
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;
}
}