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 错误验证通过
This commit is contained in:
@ -17,13 +17,16 @@ public class WildcardRegistry
|
|||||||
set { _wildcardBase = value > 0 ? value : 50; }
|
set { _wildcardBase = value > 0 ? value : 50; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The tile revealed from deck to determine random wildcards (骰子翻牌定精).</summary>
|
||||||
|
public int? RevealedTile { get; set; }
|
||||||
|
|
||||||
public WildcardRegistry() { _wildcardBase = 50; }
|
public WildcardRegistry() { _wildcardBase = 50; }
|
||||||
public WildcardRegistry(int wildcardBase) { _wildcardBase = wildcardBase > 0 ? wildcardBase : 50; }
|
public WildcardRegistry(int wildcardBase) { _wildcardBase = wildcardBase > 0 ? wildcardBase : 50; }
|
||||||
|
|
||||||
/// <summary>Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards.</summary>
|
/// <summary>Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards.</summary>
|
||||||
public IReadOnlySet<int> ConfiguredWildcards => _wildcards;
|
public IReadOnlySet<int> ConfiguredWildcards => _wildcards;
|
||||||
|
|
||||||
public void Clear() => _wildcards.Clear();
|
public void Clear() { _wildcards.Clear(); RevealedTile = null; }
|
||||||
public void Add(int tile) => _wildcards.Add(tile);
|
public void Add(int tile) => _wildcards.Add(tile);
|
||||||
|
|
||||||
public void AddRange(IEnumerable<int> tiles)
|
public void AddRange(IEnumerable<int> tiles)
|
||||||
@ -48,10 +51,46 @@ public class WildcardRegistry
|
|||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 (东→南→西→北→中→发→白→东).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="revealedTile">The tile flipped from the deck.</param>
|
||||||
|
/// <param name="count">How many next tiles to treat as wildcards (default 2 = 正精+副精).</param>
|
||||||
|
/// <returns>List of tile encodings that should become wildcards.</returns>
|
||||||
|
public static List<int> ComputeNextTiles(int revealedTile, int count = 2)
|
||||||
|
{
|
||||||
|
var result = new List<int>();
|
||||||
|
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()
|
public WildcardRegistry Clone()
|
||||||
{
|
{
|
||||||
var r = new WildcardRegistry(_wildcardBase);
|
var r = new WildcardRegistry(_wildcardBase);
|
||||||
r.AddRange(_wildcards);
|
r.AddRange(_wildcards);
|
||||||
|
r.RevealedTile = RevealedTile;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,6 +90,14 @@ public class CapabilityRegistry
|
|||||||
&& rules.WildcardRules.Behavior != "substitute")
|
&& rules.WildcardRules.Behavior != "substitute")
|
||||||
warnings.Add($"wildcard_rules.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
|
// Fu flag (过水) — now implemented
|
||||||
// No warning needed
|
// No warning needed
|
||||||
|
|
||||||
@ -152,6 +160,7 @@ public class WildcardConfig
|
|||||||
public string Type { get; set; } = "";
|
public string Type { get; set; } = "";
|
||||||
public string Behavior { get; set; } = "";
|
public string Behavior { get; set; } = "";
|
||||||
public int WildcardEncoding { get; set; } = 50;
|
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 List<string>? Tiles { get; set; }
|
||||||
public WildcardScoringConfig? Scoring { get; set; }
|
public WildcardScoringConfig? Scoring { get; set; }
|
||||||
public string FanCalculationPolicy { get; set; } = "optimal";
|
public string FanCalculationPolicy { get; set; } = "optimal";
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public class MahjongRoom
|
|||||||
private readonly MahjongScoreEngine _scoreEngine;
|
private readonly MahjongScoreEngine _scoreEngine;
|
||||||
private readonly bool _autoMode;
|
private readonly bool _autoMode;
|
||||||
private readonly Random _rng = Random.Shared;
|
private readonly Random _rng = Random.Shared;
|
||||||
|
private readonly PhaseConfig _playPhase;
|
||||||
|
|
||||||
public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false,
|
public MahjongRoom(MahjongDslRoot rules, string[] playerNames, bool autoMode = false,
|
||||||
string? humanPlayer = null)
|
string? humanPlayer = null)
|
||||||
@ -34,6 +35,7 @@ public class MahjongRoom
|
|||||||
_human = new HumanMahjongPlayer(humanPlayer, _solver);
|
_human = new HumanMahjongPlayer(humanPlayer, _solver);
|
||||||
|
|
||||||
var phases = BuildPhases();
|
var phases = BuildPhases();
|
||||||
|
_playPhase = phases.FirstOrDefault(p => p.Name == "play") ?? new PhaseConfig();
|
||||||
var (wc, r258) = GetWinParams();
|
var (wc, r258) = GetWinParams();
|
||||||
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258,
|
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258,
|
||||||
fanConfig: fanConfig,
|
fanConfig: fanConfig,
|
||||||
@ -104,7 +106,11 @@ public class MahjongRoom
|
|||||||
Action = o.Action, Priority = o.Priority, Condition = o.Condition
|
Action = o.Action, Priority = o.Priority, Condition = o.Condition
|
||||||
}).ToList() ?? new(),
|
}).ToList() ?? new(),
|
||||||
PriorityPolicy = p.SubPhases?.OthersReaction?.PriorityPolicy ?? "highest_wins",
|
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();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,7 +206,7 @@ public class MahjongRoom
|
|||||||
State.IsDeckExhausted = true;
|
State.IsDeckExhausted = true;
|
||||||
State.AddEvent("deck_exhausted", "", null, "牌墙耗尽");
|
State.AddEvent("deck_exhausted", "", null, "牌墙耗尽");
|
||||||
|
|
||||||
if (_rules.Phases.Any(p => p.ParallelElimination))
|
if (_playPhase.ParallelElimination)
|
||||||
{
|
{
|
||||||
CheckHuaZhu();
|
CheckHuaZhu();
|
||||||
CheckTing();
|
CheckTing();
|
||||||
@ -231,7 +237,7 @@ public class MahjongRoom
|
|||||||
State.AddEvent("win", player, null,
|
State.AddEvent("win", player, null,
|
||||||
$"自摸!番型: {string.Join(" + ", result?.Fans ?? new())}");
|
$"自摸!番型: {string.Join(" + ", result?.Fans ?? new())}");
|
||||||
|
|
||||||
if (_rules.Phases.Any(p => p.ParallelElimination))
|
if (_playPhase.ParallelElimination)
|
||||||
{
|
{
|
||||||
if (State.AlivePlayers.Count <= 1)
|
if (State.AlivePlayers.Count <= 1)
|
||||||
{
|
{
|
||||||
@ -364,7 +370,7 @@ public class MahjongRoom
|
|||||||
State.AlivePlayers.Remove(p);
|
State.AlivePlayers.Remove(p);
|
||||||
State.AddEvent("win", p, discardTile,
|
State.AddEvent("win", p, discardTile,
|
||||||
$"胡!{MahjongTile.ToString(discardTile)}");
|
$"胡!{MahjongTile.ToString(discardTile)}");
|
||||||
if (!_rules.Phases.Any(ph => ph.ParallelElimination))
|
if (!_playPhase.ParallelElimination)
|
||||||
{
|
{
|
||||||
_scoreEngine.Settle(State, p, winResult!, isSelfDraw: false);
|
_scoreEngine.Settle(State, p, winResult!, isSelfDraw: false);
|
||||||
IsFinished = true;
|
IsFinished = true;
|
||||||
@ -544,7 +550,7 @@ public class MahjongRoom
|
|||||||
State.HuPlayers.Add(player);
|
State.HuPlayers.Add(player);
|
||||||
State.AlivePlayers.Remove(player);
|
State.AlivePlayers.Remove(player);
|
||||||
State.AddEvent("win", player, discardTile, $"胡!{MahjongTile.ToString(discardTile)}");
|
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);
|
_scoreEngine.Settle(State, player, winResult, isSelfDraw: false);
|
||||||
Settle();
|
Settle();
|
||||||
@ -742,6 +748,19 @@ public class MahjongRoom
|
|||||||
|
|
||||||
State.AddEvent("deal_done", "", null, "发牌完成");
|
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
|
// Set first-turn flags for event-based fans
|
||||||
State.IsFirstTurn = true;
|
State.IsFirstTurn = true;
|
||||||
State.DealerOnFirstTurn = State.Dealer;
|
State.DealerOnFirstTurn = State.Dealer;
|
||||||
|
|||||||
@ -63,14 +63,17 @@ deal:
|
|||||||
### wildcard_rules (可选,有癞子时必填)
|
### wildcard_rules (可选,有癞子时必填)
|
||||||
```yaml
|
```yaml
|
||||||
wildcard_rules:
|
wildcard_rules:
|
||||||
type: fixed # "fixed"=指定牌当癞子 或 留空=编码50+的独立癞子牌
|
type: fixed # "fixed"=指定牌当癞子, "random"=骰子翻牌定精
|
||||||
tiles: [红中] # 当type=fixed时,b必须使用这些名称
|
tiles: [红中] # 当type=fixed时,必须使用这些名称
|
||||||
|
random_count: 2 # 当type=random时,正精+副精的数量(1=仅正精,2=正精+副精)
|
||||||
wildcard_encoding: 50 # 独立癞子牌的起始编码,默认50(范围50-59)
|
wildcard_encoding: 50 # 独立癞子牌的起始编码,默认50(范围50-59)
|
||||||
behavior: substitute # 当前仅支持"substitute"
|
behavior: substitute # 当前仅支持"substitute"
|
||||||
fan_calculation_policy: optimal # 当前仅支持"optimal"
|
fan_calculation_policy: optimal # 当前仅支持"optimal"
|
||||||
scoring:
|
scoring:
|
||||||
per_wildcard_in_win: 1 # 每张癞子额外加番,0=不加
|
per_wildcard_in_win: 1 # 每张癞子额外加番,0=不加
|
||||||
```
|
```
|
||||||
|
**精牌类型**:`fixed` 指定固定牌(如红中)为癞子, `random` 发牌后翻一张牌确定精——翻到X则X+1为正精, X+2为副精(9→1循环, 字牌按序循环)。
|
||||||
|
|
||||||
**固定癞子牌名映射** (tiles 字段可用值):
|
**固定癞子牌名映射** (tiles 字段可用值):
|
||||||
| YAML 名称 | 引擎编码 |
|
| YAML 名称 | 引擎编码 |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
|
|||||||
100
dsl-examples/nanchang.yaml
Normal file
100
dsl-examples/nanchang.yaml
Normal file
@ -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
|
||||||
Reference in New Issue
Block a user