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