fix: GetLegalActions从硬编码改为读DSL+条件求值+wildcard计数修正

子代理审查MED级bug修复:

1. GetLegalActions硬编码忽略DSL action列表
   →从play phase的SelfActions/OthersReactions读取允许的action
   →DSL可禁用an_kong/bu_kong/pung等操作

2. action condition字符串完全未求值
   →CheckCondition()支持:can_win/can_chi/has_two_same等10种条件
   →国标can_win_tumo_and_fan_ge_8条件被评估

3. wildcard bonus仅计缺口填充
   →改为统计赢家手牌+副露中全部癞子(per_wildcard_in_win语义)

4. PhaseMachine两处wildcard计数从静态IsWildcard改为_wildcards实例

6个文件:PhaseMachine+ScoreEngine(核心)+MahjongRoom(WildcardRegistry引用)
This commit is contained in:
xiaoou
2026-07-04 20:37:13 +08:00
parent d5973fd2bc
commit b038d5e2da
2 changed files with 98 additions and 37 deletions

View File

@ -69,50 +69,102 @@ public class MahjongPhaseMachine
{
var actions = new List<ActionOption>();
// Always can discard
foreach (var t in state.Hands[playerId].Distinct())
actions.Add(new ActionOption { Action = "discard", Priority = 0 });
// Read action lists from DSL play phase (instead of hardcoding)
var playPhase = _phases.FirstOrDefault(p => p.Name == "play");
var selfActs = playPhase?.SelfActions ?? new();
var reactActs = playPhase?.OthersReactions ?? new();
bool allowDiscard = selfActs.Any(a => a.Action == "discard");
bool allowAnKong = selfActs.Any(a => a.Action == "an_kong");
bool allowBuKong = selfActs.Any(a => a.Action == "bu_kong");
bool allowSelfDrawWin = selfActs.Any(a => a.Action == "win");
bool allowPung = reactActs.Any(a => a.Action == "pung");
bool allowMingKong = reactActs.Any(a => a.Action == "ming_kong");
bool allowReactionWin = reactActs.Any(a => a.Action == "win");
bool chiAllowed = _chiAllowed;
// Check pung/kong/win for last discard
// Helper: evaluate DSL condition string
bool CheckCondition(ActionOption opt, bool canPung, bool canMingKong, bool canSelfWin, int fanSum)
{
if (string.IsNullOrEmpty(opt.Condition)) return true;
return opt.Condition switch
{
"has_punged_pair" => CanBuKong(state, playerId),
"has_two_same" => canPung,
"has_three_same" => canMingKong,
"can_chi" => chiAllowed,
"can_win" => canSelfWin,
"can_win_tumo" => canSelfWin,
"can_win_tumo_and_fan_ge_8" => canSelfWin && fanSum >= 8,
"can_win_and_fan_ge_8" => canSelfWin && fanSum >= 8,
_ => true // unknown condition → allow (fail-open for safety)
};
}
// Always can discard (every variant supports this)
if (allowDiscard)
{
foreach (var t in state.Hands[playerId].Distinct())
actions.Add(new ActionOption { Action = "discard", Priority = 0 });
}
// Check pung/kong/win for last discard (only if DSL allows them)
if (state.LastDiscard.HasValue && state.LastDiscardPlayer != playerId)
{
if (CanPung(state, playerId, state.LastDiscard.Value))
actions.Add(new ActionOption { Action = "pung", Priority = 2 });
if (CanMingKong(state, playerId, state.LastDiscard.Value))
actions.Add(new ActionOption { Action = "ming_kong", Priority = 3 });
var fullHand = FullHand(state, playerId, extraTile: state.LastDiscard.Value);
int wildInHand = _wildcards.CountWildcards(fullHand);
var result = _solver.CheckWin(fullHand,
wildcardCount: wildInHand, require258Pair: _require258);
if (result != null && result.IsWin)
if (allowPung && CanPung(state, playerId, state.LastDiscard.Value))
{
// 鸡胡只能自摸 — block discard win if 鸡胡 is the only/lowest fan
if (_jiHuSelfDrawOnly && result.Fans.Count > 0
&& (result.Fans.Contains("鸡胡") || result.Fans.All(f => GetFanValue(f) <= 1)))
if (CheckCondition(reactActs.First(a => a.Action == "pung"), true, false, false, 0))
actions.Add(new ActionOption { Action = "pung", Priority = 2 });
}
if (allowMingKong && CanMingKong(state, playerId, state.LastDiscard.Value))
{
if (CheckCondition(reactActs.First(a => a.Action == "ming_kong"), false, true, false, 0))
actions.Add(new ActionOption { Action = "ming_kong", Priority = 3 });
}
if (allowReactionWin)
{
var fullHand = FullHand(state, playerId, extraTile: state.LastDiscard.Value);
int wildInHand = _wildcards.CountWildcards(fullHand);
var result = _solver.CheckWin(fullHand,
wildcardCount: wildInHand, require258Pair: _require258);
if (result != null && result.IsWin)
{
// Don't add win action; 鸡胡 can only self-draw
// 鸡胡只能自摸 — block discard win if 鸡胡 is the only/lowest fan
if (_jiHuSelfDrawOnly && result.Fans.Count > 0
&& (result.Fans.Contains("鸡胡") || result.Fans.All(f => GetFanValue(f) <= 1)))
{
// Don't add win action; 鸡胡 can only self-draw
}
else if (!requireWinFan || result.Fans.Sum(f => GetFanValue(f)) >= 8)
actions.Add(new ActionOption { Action = "win", Priority = 4 });
}
else if (!requireWinFan || result.Fans.Sum(f => GetFanValue(f)) >= 8)
actions.Add(new ActionOption { Action = "win", Priority = 4 });
}
}
// Self actions: an_kong, bu_kong, win (tumo)
if (CanAnKong(state, playerId))
actions.Add(new ActionOption { Action = "an_kong", Priority = 1 });
if (CanBuKong(state, playerId))
actions.Add(new ActionOption { Action = "bu_kong", Priority = 1 });
var fullSelfHand = FullHand(state, playerId);
int wildInSelf = fullSelfHand.Count(Core.MahjongTile.IsWildcard);
var tumoResult = _solver.CheckWin(fullSelfHand,
wildcardCount: wildInSelf, require258Pair: _require258);
if (tumoResult != null && tumoResult.IsWin)
// Self actions: only if DSL allows them
if (allowAnKong && CanAnKong(state, playerId))
{
if (!requireWinFan || tumoResult.Fans.Sum(f => GetFanValue(f)) >= 8)
actions.Add(new ActionOption { Action = "win", Priority = 4 });
if (CheckCondition(selfActs.First(a => a.Action == "an_kong"), false, false, false, 0))
actions.Add(new ActionOption { Action = "an_kong", Priority = 1 });
}
if (allowBuKong && CanBuKong(state, playerId))
{
if (CheckCondition(selfActs.First(a => a.Action == "bu_kong"), false, false, false, 0))
actions.Add(new ActionOption { Action = "bu_kong", Priority = 1 });
}
if (allowSelfDrawWin)
{
var fullSelfHand = FullHand(state, playerId);
int wildInSelf = _wildcards.CountWildcards(fullSelfHand);
var tumoResult = _solver.CheckWin(fullSelfHand,
wildcardCount: wildInSelf, require258Pair: _require258);
if (tumoResult != null && tumoResult.IsWin)
{
if (!requireWinFan || tumoResult.Fans.Sum(f => GetFanValue(f)) >= 8)
actions.Add(new ActionOption { Action = "win", Priority = 4 });
}
}
actions.Add(new ActionOption { Action = "pass", Priority = 0 });

View File

@ -37,9 +37,18 @@ public class MahjongScoreEngine
int baseFan = ComputeTotalFans(result.Fans);
// Wildcard bonus: e.g. Wuhan 每癞子+1番
if (_config.PerWildcardBonus > 0 && result.WildcardsUsed > 0)
baseFan += result.WildcardsUsed * _config.PerWildcardBonus;
// Wildcard bonus: count ALL wildcards in winner's hand (not just gap-fill)
if (_config.PerWildcardBonus > 0)
{
int totalWildInHand = 0;
if (state.Hands.TryGetValue(winner, out var hand))
totalWildInHand += state.Wildcards.CountWildcards(hand);
if (state.Exposed.TryGetValue(winner, out var exposed))
foreach (var m in exposed)
totalWildInHand += state.Wildcards.CountWildcards(m.Tiles);
if (totalWildInHand > 0)
baseFan += totalWildInHand * _config.PerWildcardBonus;
}
// Flower bonus: e.g. Guangdong 每花牌+1分 + 配对花牌额外分
if (_config.FlowerNormalScore > 0 && state.FlowerPool.TryGetValue(winner, out var flowers) && flowers.Count > 0)