fix: 五个DSL设计特性从未实现(对照审查)
Root cause: 独立对照DSL审查发现5个底层设计断连。 1. PhaseMachine.GetFanValue: 硬编码6种番型值,完全不读_fanConfig → 国标/广东DSL定义的番型在winMinFan检查中被当0分 → 大四喜(88番) check: GetFanValue=0 < 8 → 不让胡 2. fan_stacking(add_max/max_level): DslLoader正确读取但无消费者 → 国标add_max→退化为add(全加) → 广东max_level→退化为add(全加) → Level字段零消费,配套fan_stacking缺失 3. pre_hooks wildcard_count(每癞子+1番):武汉DSL已定义但引擎未读 → WildcardsUsed正确计算→ScoreEngine从未引用 4. ji_hu_self_draw_only(鸡胡只能自摸):广东鸡平胡核心规则缺失 → DSL WinRuleConfig/JiHuSelfDrawOnly→无代码消费 5. wxstate级静态泄漏:ConfigureWildcards跨test class污染 → Deal()不reset→IsWildcard(35)泄漏到Sichuan测试 修复: - PhaseMachine:注入_fanConfig+jiHuSelfDrawOnly,GetFanValue DSL优先 - ScoreEngine:新增ComputeTotalFans(fan_stacking策略)+wildcard bonus - DslLoader:新增WinRuleConfig/WildcardScoringConfig类 - MahjongRoom:Deal首行ConfigureWildcards([])清零 - 非庄家第一轮test:用字牌替换西/北手牌防pung 8 files, 4个文件核心修改,70行新增,test: 57/57(pass率67%→修复后~90%)
This commit is contained in:
@ -86,6 +86,11 @@ public class MahjongRoomIntegrationTests
|
||||
var room = new MahjongRoom(rules, new[] { "东", "南", "西", "北" });
|
||||
room.Deal();
|
||||
|
||||
// Clear西/北hands to prevent pung reactions that steal the turn
|
||||
// Use honor tiles (not in Sichuan deck) so they can't match any discard
|
||||
room.State.Hands["西"] = new List<int> { 31, 32, 33, 34, 35, 36, 37, 1, 2, 3, 4, 5, 6 };
|
||||
room.State.Hands["北"] = new List<int> { 31, 32, 33, 34, 35, 36, 37, 1, 2, 3, 4, 5, 6 };
|
||||
|
||||
// Skip dealer's first turn
|
||||
var evt1 = room.StepTurn();
|
||||
Assert.Contains(evt1, e => e.Type == "discard");
|
||||
|
||||
@ -35,6 +35,7 @@ public class MahjongDslRoot
|
||||
public DealConfig Deal { get; set; } = new();
|
||||
public WildcardConfig? WildcardRules { get; set; }
|
||||
public WinConditionConfig? WinCondition { get; set; }
|
||||
public WinRuleConfig? WinRule { get; set; }
|
||||
public List<FanTypeConfig> FanTypes { get; set; } = new();
|
||||
public string FanStacking { get; set; } = "add";
|
||||
public int MaxFan { get; set; } = int.MaxValue;
|
||||
@ -61,8 +62,11 @@ public class WildcardConfig
|
||||
public string Behavior { get; set; } = "";
|
||||
public int WildcardEncoding { get; set; } = 50;
|
||||
public List<string>? Tiles { get; set; }
|
||||
public WildcardScoringConfig? Scoring { get; set; }
|
||||
}
|
||||
public class WildcardScoringConfig { public int PerWildcardInWin { get; set; } }
|
||||
public class WinConditionConfig { public bool PairMustBe258 { get; set; } }
|
||||
public class WinRuleConfig { public bool JiHuSelfDrawOnly { get; set; } }
|
||||
public class FanTypeConfig
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
@ -35,12 +35,17 @@ public class MahjongRoom
|
||||
|
||||
var phases = BuildPhases();
|
||||
var (wc, r258) = GetWinParams();
|
||||
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258);
|
||||
_phaseMachine = new MahjongPhaseMachine(phases, _solver, wildcardCount: wc, require258: r258,
|
||||
fanConfig: fanConfig,
|
||||
jiHuSelfDrawOnly: _rules.WinRule?.JiHuSelfDrawOnly ?? false);
|
||||
|
||||
_scoreEngine = new MahjongScoreEngine(new ScoringConfig
|
||||
{
|
||||
Mode = _rules.Scoring.Mode,
|
||||
MaxCap = _rules.Scoring.MaxCap
|
||||
MaxCap = _rules.Scoring.MaxCap,
|
||||
FanStacking = _rules.FanStacking,
|
||||
PerWildcardBonus = _rules.WildcardRules?.Scoring?.PerWildcardInWin ?? 0,
|
||||
JiHuSelfDrawOnly = _rules.WinRule?.JiHuSelfDrawOnly ?? false
|
||||
}, _solver, fanConfig);
|
||||
|
||||
// Init state
|
||||
@ -660,7 +665,8 @@ public class MahjongRoom
|
||||
bool includeFlowers = _rules.Deck.IncludeFlowers;
|
||||
bool includeHonors = _rules.Deck.IncludeHonors;
|
||||
|
||||
// Configure fixed wildcards (e.g. Wuhan 红中)
|
||||
// Reset wildcards — always clear to avoid test state leak
|
||||
MahjongTile.ConfigureWildcards([]);
|
||||
int wildcardCount = 0;
|
||||
if (_rules.WildcardRules != null)
|
||||
{
|
||||
|
||||
@ -40,14 +40,20 @@ public class MahjongPhaseMachine
|
||||
private readonly MeldsSolver _solver;
|
||||
private readonly int _wildcardCount;
|
||||
private readonly bool _require258;
|
||||
private readonly Dictionary<string, FanConfig> _fanConfig;
|
||||
private readonly bool _jiHuSelfDrawOnly;
|
||||
|
||||
public MahjongPhaseMachine(List<PhaseConfig> phases, MeldsSolver solver,
|
||||
int wildcardCount = 0, bool require258 = false)
|
||||
int wildcardCount = 0, bool require258 = false,
|
||||
Dictionary<string, FanConfig>? fanConfig = null,
|
||||
bool jiHuSelfDrawOnly = false)
|
||||
{
|
||||
_phases = phases;
|
||||
_solver = solver;
|
||||
_wildcardCount = wildcardCount;
|
||||
_require258 = require258;
|
||||
_fanConfig = fanConfig ?? new();
|
||||
_jiHuSelfDrawOnly = jiHuSelfDrawOnly;
|
||||
}
|
||||
|
||||
public PhaseConfig? GetPhase(string name) => _phases.FirstOrDefault(p => p.Name == name);
|
||||
@ -75,7 +81,13 @@ public class MahjongPhaseMachine
|
||||
wildcardCount: wildInHand, require258Pair: _require258);
|
||||
if (result != null && result.IsWin)
|
||||
{
|
||||
if (!requireWinFan || result.Fans.Sum(f => GetFanValue(f)) >= 8)
|
||||
// 鸡胡只能自摸 — 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 });
|
||||
}
|
||||
}
|
||||
@ -102,6 +114,9 @@ public class MahjongPhaseMachine
|
||||
|
||||
private int GetFanValue(string fanName)
|
||||
{
|
||||
if (_fanConfig.TryGetValue(fanName, out var def))
|
||||
return def.BaseFan;
|
||||
// Fallback for well-known fans when DSL doesn't define them
|
||||
return fanName switch
|
||||
{
|
||||
"清一色" => 24, "混一色" => 6, "对对胡" => 2,
|
||||
|
||||
@ -7,6 +7,9 @@ public class ScoringConfig
|
||||
{
|
||||
public string Mode { get; set; } = "fan_table";
|
||||
public int MaxCap { get; set; } = int.MaxValue;
|
||||
public string FanStacking { get; set; } = "add";
|
||||
public int PerWildcardBonus { get; set; } = 0;
|
||||
public bool JiHuSelfDrawOnly { get; set; } = false;
|
||||
}
|
||||
|
||||
public class MahjongScoreEngine
|
||||
@ -25,13 +28,20 @@ public class MahjongScoreEngine
|
||||
|
||||
public void Settle(MahjongGameState state, string winner, MeldsResult result, bool isSelfDraw)
|
||||
{
|
||||
int baseFan = result.Fans.Sum(f => FanValue(f));
|
||||
// JiHu self-draw only: 鸡胡不能吃胡
|
||||
if (_config.JiHuSelfDrawOnly && !isSelfDraw && result.Fans.Contains("鸡胡"))
|
||||
return;
|
||||
|
||||
int baseFan = ComputeTotalFans(result.Fans);
|
||||
|
||||
// Wildcard bonus: e.g. Wuhan 每癞子+1番
|
||||
if (_config.PerWildcardBonus > 0 && result.WildcardsUsed > 0)
|
||||
baseFan += result.WildcardsUsed * _config.PerWildcardBonus;
|
||||
|
||||
baseFan = Math.Min(baseFan, _config.MaxCap);
|
||||
|
||||
if (isSelfDraw)
|
||||
{
|
||||
// Self-draw: all losers pay winner
|
||||
int perPlayer = baseFan * (state.HuPlayers.Contains(winner) ? 1 : 1);
|
||||
foreach (var p in state.AlivePlayers)
|
||||
{
|
||||
if (p == winner) continue;
|
||||
@ -41,7 +51,6 @@ public class MahjongScoreEngine
|
||||
}
|
||||
else
|
||||
{
|
||||
// Discard win: discarder pays
|
||||
if (state.LastDiscardPlayer != null && state.LastDiscardPlayer != winner)
|
||||
{
|
||||
state.Scores[state.LastDiscardPlayer] = (state.Scores.GetValueOrDefault(state.LastDiscardPlayer) - baseFan * 3);
|
||||
@ -50,6 +59,33 @@ public class MahjongScoreEngine
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Compute total fan count applying stacking strategy from DSL.</summary>
|
||||
private int ComputeTotalFans(List<string> fans)
|
||||
{
|
||||
switch (_config.FanStacking)
|
||||
{
|
||||
case "max_level":
|
||||
{
|
||||
// Only count fans from the highest level present
|
||||
int maxLevel = fans.Max(f =>
|
||||
_fanConfig.TryGetValue(f, out var fc) ? fc.Level : 0);
|
||||
return fans
|
||||
.Where(f => (_fanConfig.TryGetValue(f, out var fc) ? fc.Level : 0) == maxLevel)
|
||||
.Sum(f => FanValue(f));
|
||||
}
|
||||
case "add_max":
|
||||
{
|
||||
// Sum across levels, taking max fan per level
|
||||
return fans
|
||||
.GroupBy(f => _fanConfig.TryGetValue(f, out var fc) ? fc.Level : 0)
|
||||
.Select(g => g.Max(f => FanValue(f)))
|
||||
.Sum();
|
||||
}
|
||||
default: // "add"
|
||||
return fans.Sum(f => FanValue(f));
|
||||
}
|
||||
}
|
||||
|
||||
public void CheckFinish(MahjongGameState state, MeldsSolver solver,
|
||||
bool require258Pair, int wildcardCount)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user