From ae9c4c414284d4151b1d47a601fc922e8e14d919 Mon Sep 17 00:00:00 2001 From: xiaoou Date: Sat, 4 Jul 2026 21:06:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20EndConditions=E6=98=A0=E5=B0=84=20+=20?= =?UTF-8?q?=E9=AA=B0=E5=AD=90=E7=BF=BB=E7=89=8C=E5=AE=9A=E7=B2=BE(random?= =?UTF-8?q?=20wildcard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 错误验证通过 --- RuleEngine/Core/WildcardRegistry.cs | 41 +++++++++++- RuleEngine/Dsl/DslLoader.cs | 9 +++ RuleEngine/MahjongRoom.cs | 29 ++++++-- docs/dsl-specification.md | 7 +- dsl-examples/nanchang.yaml | 100 ++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 dsl-examples/nanchang.yaml diff --git a/RuleEngine/Core/WildcardRegistry.cs b/RuleEngine/Core/WildcardRegistry.cs index 8af2c89..05890f1 100644 --- a/RuleEngine/Core/WildcardRegistry.cs +++ b/RuleEngine/Core/WildcardRegistry.cs @@ -17,13 +17,16 @@ public class WildcardRegistry set { _wildcardBase = value > 0 ? value : 50; } } + /// The tile revealed from deck to determine random wildcards (骰子翻牌定精). + public int? RevealedTile { get; set; } + public WildcardRegistry() { _wildcardBase = 50; } public WildcardRegistry(int wildcardBase) { _wildcardBase = wildcardBase > 0 ? wildcardBase : 50; } /// Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards. public IReadOnlySet ConfiguredWildcards => _wildcards; - public void Clear() => _wildcards.Clear(); + public void Clear() { _wildcards.Clear(); RevealedTile = null; } public void Add(int tile) => _wildcards.Add(tile); public void AddRange(IEnumerable tiles) @@ -48,10 +51,46 @@ public class WildcardRegistry _ => null }; + /// + /// Compute wildcard tiles from a revealed tile (翻牌定精). + /// Numbered suits: rank+1 is main 精, rank+2 is secondary 精 (wraps 9→1). + /// Honor tiles: next in honor sequence (东→南→西→北→中→发→白→东). + /// + /// The tile flipped from the deck. + /// How many next tiles to treat as wildcards (default 2 = 正精+副精). + /// List of tile encodings that should become wildcards. + public static List ComputeNextTiles(int revealedTile, int count = 2) + { + var result = new List(); + int suit = MahjongTile.Suit(revealedTile); + + if (suit == 3) // Honors: 东(31)→南(32)→西(33)→北(34)→中(35)→发(36)→白(37)→东(31) + { + for (int i = 1; i <= count; i++) + { + int next = revealedTile + i; + if (next > 37) next = 31 + (next - 38); + result.Add(next); + } + } + else if (suit is >= 0 and <= 2) // Numbered: 万(1-9) / 条(11-19) / 筒(21-29) + { + int baseVal = suit switch { 0 => 0, 1 => 10, _ => 20 }; + int rank = MahjongTile.Rank(revealedTile); + for (int i = 1; i <= count; i++) + { + int nextRank = (rank + i - 1) % 9 + 1; + result.Add(baseVal + nextRank); + } + } + return result; + } + public WildcardRegistry Clone() { var r = new WildcardRegistry(_wildcardBase); r.AddRange(_wildcards); + r.RevealedTile = RevealedTile; return r; } } diff --git a/RuleEngine/Dsl/DslLoader.cs b/RuleEngine/Dsl/DslLoader.cs index 9990403..68ef2b9 100644 --- a/RuleEngine/Dsl/DslLoader.cs +++ b/RuleEngine/Dsl/DslLoader.cs @@ -90,6 +90,14 @@ public class CapabilityRegistry && 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 { "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 @@ -152,6 +160,7 @@ 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? Tiles { get; set; } public WildcardScoringConfig? Scoring { get; set; } public string FanCalculationPolicy { get; set; } = "optimal"; diff --git a/RuleEngine/MahjongRoom.cs b/RuleEngine/MahjongRoom.cs index e23430c..d0895bd 100644 --- a/RuleEngine/MahjongRoom.cs +++ b/RuleEngine/MahjongRoom.cs @@ -19,6 +19,7 @@ public class MahjongRoom private readonly MahjongScoreEngine _scoreEngine; private readonly bool _autoMode; private readonly Random _rng = Random.Shared; + private readonly PhaseConfig _playPhase; public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false, string? humanPlayer = null) @@ -34,6 +35,7 @@ public class MahjongRoom _human = new HumanMahjongPlayer(humanPlayer, _solver); var phases = BuildPhases(); + _playPhase = phases.FirstOrDefault(p => p.Name == "play") ?? new PhaseConfig(); var (wc, r258) = GetWinParams(); _phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258, fanConfig: fanConfig, @@ -104,7 +106,11 @@ public class MahjongRoom Action = o.Action, Priority = o.Priority, Condition = o.Condition }).ToList() ?? new(), PriorityPolicy = p.SubPhases?.OthersReaction?.PriorityPolicy ?? "highest_wins", - OnWin = p.SubPhases?.OthersReaction?.OnWin + OnWin = p.SubPhases?.OthersReaction?.OnWin, + EndConditions = p.EndConditions?.Select(e => new EndCondition + { + Type = e.Type, Action = e.Action + }).ToList() ?? new() }).ToList(); } @@ -200,7 +206,7 @@ public class MahjongRoom State.IsDeckExhausted = true; State.AddEvent("deck_exhausted", "", null, "牌墙耗尽"); - if (_rules.Phases.Any(p => p.ParallelElimination)) + if (_playPhase.ParallelElimination) { CheckHuaZhu(); CheckTing(); @@ -231,7 +237,7 @@ public class MahjongRoom State.AddEvent("win", player, null, $"自摸!番型: {string.Join(" + ", result?.Fans ?? new())}"); - if (_rules.Phases.Any(p => p.ParallelElimination)) + if (_playPhase.ParallelElimination) { if (State.AlivePlayers.Count <= 1) { @@ -364,7 +370,7 @@ public class MahjongRoom State.AlivePlayers.Remove(p); State.AddEvent("win", p, discardTile, $"胡!{MahjongTile.ToString(discardTile)}"); - if (!_rules.Phases.Any(ph => ph.ParallelElimination)) + if (!_playPhase.ParallelElimination) { _scoreEngine.Settle(State, p, winResult!, isSelfDraw: false); IsFinished = true; @@ -544,7 +550,7 @@ public class MahjongRoom State.HuPlayers.Add(player); State.AlivePlayers.Remove(player); State.AddEvent("win", player, discardTile, $"胡!{MahjongTile.ToString(discardTile)}"); - if (!_rules.Phases.Any(ph => ph.ParallelElimination)) + if (!_playPhase.ParallelElimination) { _scoreEngine.Settle(State, player, winResult, isSelfDraw: false); Settle(); @@ -742,6 +748,19 @@ public class MahjongRoom State.AddEvent("deal_done", "", null, "发牌完成"); + // Random wildcard (骰子翻牌定精): flip one tile from remaining deck to determine 精 + if (_rules.WildcardRules?.Type == "random" && State.Deck.Count > 0) + { + int revealed = State.Deck[^1]; + State.Deck.RemoveAt(State.Deck.Count - 1); + int count = _rules.WildcardRules.RandomCount > 0 ? _rules.WildcardRules.RandomCount : 2; + var wildcardTiles = WildcardRegistry.ComputeNextTiles(revealed, count); + State.Wildcards.AddRange(wildcardTiles); + State.Wildcards.RevealedTile = revealed; + State.AddEvent("reveal_wildcard", "", revealed, + $"翻牌定精: {MahjongTile.ToString(revealed)} → 精: {string.Join(", ", wildcardTiles.Select(t => MahjongTile.ToString(t, State.Wildcards)))}"); + } + // Set first-turn flags for event-based fans State.IsFirstTurn = true; State.DealerOnFirstTurn = State.Dealer; diff --git a/docs/dsl-specification.md b/docs/dsl-specification.md index 0b4e656..e81e041 100644 --- a/docs/dsl-specification.md +++ b/docs/dsl-specification.md @@ -63,14 +63,17 @@ deal: ### wildcard_rules (可选,有癞子时必填) ```yaml wildcard_rules: - type: fixed # "fixed"=指定牌当癞子 或 留空=编码50+的独立癞子牌 - tiles: [红中] # 当type=fixed时,b必须使用这些名称 + type: fixed # "fixed"=指定牌当癞子, "random"=骰子翻牌定精 + tiles: [红中] # 当type=fixed时,必须使用这些名称 + random_count: 2 # 当type=random时,正精+副精的数量(1=仅正精,2=正精+副精) wildcard_encoding: 50 # 独立癞子牌的起始编码,默认50(范围50-59) behavior: substitute # 当前仅支持"substitute" fan_calculation_policy: optimal # 当前仅支持"optimal" scoring: per_wildcard_in_win: 1 # 每张癞子额外加番,0=不加 ``` +**精牌类型**:`fixed` 指定固定牌(如红中)为癞子, `random` 发牌后翻一张牌确定精——翻到X则X+1为正精, X+2为副精(9→1循环, 字牌按序循环)。 + **固定癞子牌名映射** (tiles 字段可用值): | YAML 名称 | 引擎编码 | |-----------|---------| diff --git a/dsl-examples/nanchang.yaml b/dsl-examples/nanchang.yaml new file mode 100644 index 0000000..ab3aa70 --- /dev/null +++ b/dsl-examples/nanchang.yaml @@ -0,0 +1,100 @@ +game: + name: 南昌麻将 + type: mahjong + engine_type: mahjong + players: { min: 4, max: 4 } + +requires: + - deck.generator_mahjong + - meldsolver.standard_win + - meldsolver.seven_pairs + - meldsolver.wildcard + - phase.mahjong_turn + - phase.priority_arbitration + - scoring.fan_exclusion + +deck: + generator: mahjong + include_honors: true + include_flowers: false + total: 136 + +deal: + cards_per_player: 13 + dealer_extra: 1 + +# 南昌麻将的精(癞子)系统:骰子翻牌定精 +# 翻到X,则X+1为正精,X+2为副精。字牌按序循环。 +wildcard_rules: + type: random + random_count: 2 + wildcard_encoding: 50 + behavior: substitute + fan_calculation_policy: optimal + scoring: + per_wildcard_in_win: 1 + +fan_types: + # 基础番型 + - { name: 清一色, base_fan: 4, excludes: [缺一门, 无字] } + - { name: 混一色, base_fan: 2, excludes: [缺一门] } + - { name: 对对胡, base_fan: 2 } + - { name: 暗七对, base_fan: 4 } + # 特殊牌型 + - { name: 带幺九, base_fan: 2, excludes: [缺一门] } + - { name: 缺一门, base_fan: 0 } + - { name: 无字, base_fan: 0 } + # 事件番型 + - { name: 杠上开花, base_fan: 1, excludes: [海底捞月] } + - { name: 海底捞月, base_fan: 1 } + - { name: 抢杠胡, base_fan: 1 } + - { name: 天胡, base_fan: 10 } + - { name: 地胡, base_fan: 5 } + # 癞子相关 + - { name: 癞子胡, base_fan: 1, condition: hand_contains_wildcard } + # 兜底 + - { name: 鸡胡, base_fan: 1 } + +fan_stacking: add +max_fan: 999 + +phases: + - name: deal + type: auto + action: deal_cards + next: play + + - name: play + type: mahjong_turn + turn_order: counter_clockwise + sub_phases: + draw: + type: auto + action: draw_card + on_empty_deck: exhausted + self_action: + options: + - { action: discard } + - { action: an_kong } + - { action: bu_kong, condition: has_punged_pair } + - { action: win, condition: can_win_tumo } + others_reaction: + options: + - { action: pung, priority: 2, condition: has_two_same } + - { action: ming_kong, priority: 3, condition: has_three_same } + - { action: win, priority: 4, condition: can_win } + - { action: pass, priority: 0 } + priority_policy: highest_wins + on_win: game_over + end_conditions: + - { type: player_wins, action: settle } + - { type: deck_exhausted, action: draw_game } + + - name: settle + type: auto + action: calculate_scores + next: null + +scoring: + mode: fan_table + max_cap: 999 \ No newline at end of file