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:
xiaoou
2026-07-04 21:06:16 +08:00
parent 5b60255f02
commit ae9c4c4142
5 changed files with 178 additions and 8 deletions

View File

@ -17,13 +17,16 @@ public class WildcardRegistry
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(int wildcardBase) { _wildcardBase = wildcardBase > 0 ? wildcardBase : 50; }
/// <summary>Non-extra wildcard tiles — normal tiles (e.g. 红中=35) that ALSO act as wildcards.</summary>
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 AddRange(IEnumerable<int> tiles)
@ -48,10 +51,46 @@ public class WildcardRegistry
_ => 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()
{
var r = new WildcardRegistry(_wildcardBase);
r.AddRange(_wildcards);
r.RevealedTile = RevealedTile;
return r;
}
}

View File

@ -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<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
@ -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<string>? Tiles { get; set; }
public WildcardScoringConfig? Scoring { get; set; }
public string FanCalculationPolicy { get; set; } = "optimal";

View File

@ -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;