ScoreEngine.FanValue 和 PhaseMachine.GetFanValue 之前各自维护 一份相同的硬编码 fallback 列表。改为 ScoreEngine 调用 PhaseMachine.DefaultFanValue() 单例消除重复。 硬编码审计结论: 所有影响变体适配的硬编码已全部修复, 剩余的是麻将基础规则(碰2/杠3/暗杠4/13/14张)和牌编码定义。
175 lines
6.7 KiB
C#
175 lines
6.7 KiB
C#
namespace RuleEngine.Scoring;
|
|
|
|
using RuleEngine.Core;
|
|
using RuleEngine.Patterns;
|
|
|
|
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 int FlowerNormalScore { get; set; } = 0;
|
|
public int FlowerMatchScore { get; set; } = 0;
|
|
public Dictionary<int, List<string>>? FlowerMapping { get; set; }
|
|
public int SelfDrawMultiplier { get; set; } = 1;
|
|
public int DiscardWinMultiplier { get; set; } = 3;
|
|
public int DealerMultiplier { get; set; } = 1;
|
|
}
|
|
|
|
public class MahjongScoreEngine
|
|
{
|
|
private readonly ScoringConfig _config;
|
|
private readonly MeldsSolver _solver;
|
|
private readonly Dictionary<string, FanConfig> _fanConfig;
|
|
|
|
public MahjongScoreEngine(ScoringConfig config, MeldsSolver solver,
|
|
Dictionary<string, FanConfig> fanConfig)
|
|
{
|
|
_config = config;
|
|
_solver = solver;
|
|
_fanConfig = fanConfig;
|
|
}
|
|
|
|
public void Settle(MahjongGameState state, string winner, MeldsResult result, bool isSelfDraw)
|
|
{
|
|
// JiHu self-draw only: 鸡胡不能吃胡
|
|
if (_config.JiHuSelfDrawOnly && !isSelfDraw && result.Fans.Contains("鸡胡"))
|
|
return;
|
|
|
|
int baseFan = ComputeTotalFans(result.Fans);
|
|
|
|
// 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)
|
|
{
|
|
baseFan += flowers.Count * _config.FlowerNormalScore;
|
|
if (_config.FlowerMatchScore > 0 && _config.FlowerMapping != null)
|
|
{
|
|
// Count matching position+season pairs
|
|
var flowerNames = flowers.Select(f => FlowerName(f)).ToHashSet();
|
|
foreach (var (pos, names) in _config.FlowerMapping)
|
|
{
|
|
int matches = names.Count(n => flowerNames.Contains(n));
|
|
if (matches >= 2)
|
|
baseFan += _config.FlowerMatchScore;
|
|
}
|
|
}
|
|
}
|
|
|
|
baseFan = Math.Min(baseFan, _config.MaxCap);
|
|
|
|
if (isSelfDraw)
|
|
{
|
|
int perPlayerPay = baseFan * _config.SelfDrawMultiplier;
|
|
bool dealerWon = (state.Dealer == winner);
|
|
foreach (var p in state.AlivePlayers)
|
|
{
|
|
if (p == winner) continue;
|
|
// 庄家赢→每家付倍数; 闲家赢→仅庄家付倍数
|
|
int mul = (dealerWon || p == state.Dealer) ? _config.DealerMultiplier : 1;
|
|
int pay = perPlayerPay * mul;
|
|
state.Scores[p] = (state.Scores.GetValueOrDefault(p) - pay);
|
|
state.Scores[winner] = (state.Scores.GetValueOrDefault(winner) + pay);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (state.LastDiscardPlayer != null && state.LastDiscardPlayer != winner)
|
|
{
|
|
int discardPay = baseFan * _config.DiscardWinMultiplier;
|
|
// 庄家赢了或庄家放炮→加倍
|
|
bool dealerInvolved = (state.Dealer == winner || state.Dealer == state.LastDiscardPlayer);
|
|
int mul = dealerInvolved ? _config.DealerMultiplier : 1;
|
|
int pay = discardPay * mul;
|
|
state.Scores[state.LastDiscardPlayer] = (state.Scores.GetValueOrDefault(state.LastDiscardPlayer) - pay);
|
|
state.Scores[winner] = (state.Scores.GetValueOrDefault(winner) + pay);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
// Check each alive player for ting
|
|
foreach (var p in state.AlivePlayers)
|
|
{
|
|
var hand = state.Hands[p];
|
|
if (hand.Count <= 14)
|
|
{
|
|
// Simple: check if removing any one tile leads to win
|
|
bool hasTing = false;
|
|
foreach (var t in hand)
|
|
{
|
|
var testHand = new List<int>(hand);
|
|
testHand.Remove(t);
|
|
var r = solver.CheckWin(testHand, wildcardCount: wildcardCount, require258Pair: require258Pair);
|
|
if (r != null && r.IsWin)
|
|
{
|
|
hasTing = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasTing)
|
|
state.AddEvent("ting_checked", p, null, "听牌");
|
|
else
|
|
state.AddEvent("ting_failed", p, null, "不听牌 — 罚分");
|
|
}
|
|
}
|
|
}
|
|
|
|
private int FanValue(string fan)
|
|
{
|
|
if (_fanConfig.TryGetValue(fan, out var def))
|
|
return def.BaseFan;
|
|
return Phase.MahjongPhaseMachine.DefaultFanValue(fan);
|
|
}
|
|
|
|
private static string FlowerName(int tile) => tile switch
|
|
{
|
|
41 => "春", 42 => "夏", 43 => "秋", 44 => "冬",
|
|
45 => "梅", 46 => "兰", 47 => "竹", 48 => "菊",
|
|
_ => "?"
|
|
};
|
|
}
|