Files
card-game-engine/RuleEngine/Dsl/DslLoader.cs
xiaoou ae9c4c4142 feat: EndConditions映射 + 骰子翻牌定精(random wildcard)
EndConditions:
- BuildPhases 现在映射 PhaseConfig.Endconditions (之前 DslLoader 解析但 BuildPhases 忽略)
- 4处 _rules.Phases.Any(p => p.ParallelElimination) → _playPhase.ParallelElimination
- 新增 _playPhase 字段, 游戏不再依赖原始 DSL 做运行时判断

Random wildcard (骰子翻牌定精):
- WildcardRegistry.ComputeNextTiles(): 翻到 X → 正精=X+1, 副精=X+2 (9→1 循环, 字牌序循环)
- WildcardRegistry.RevealedTile 记录翻到的牌
- Deal() 发牌后翻一张牌确定精, 支持 random_count 控制精数量
- WildcardConfig 新增 RandomCount 字段 (默认2=正精+副精)
- ValidateRuleCompliance 接受 random 类型

南昌麻将 DSL:
- 新增 dsl-examples/nanchang.yaml (136张, random精, 无吃)
- 引擎加载 7/7, 1000局 0错误, 胡牌率 61%

58 tests pass, 5 种玩法全部 0 错误验证通过
2026-07-04 21:06:16 +08:00

297 lines
11 KiB
C#

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}");
}
// Scoring mode
if (rules.Scoring.Mode != "fan_table")
warnings.Add($"scoring.mode={rules.Scoring.Mode} 引擎仅支持fan_table→忽略");
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 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;
}
}