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 错误验证通过
297 lines
11 KiB
C#
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;
|
|
}
|
|
}
|