Files
card-game-engine/RuleEngine/Scoring/ScoreEngine.cs
xiaoou b7fb93d4e4 refactor: FanValue fallback 集中到 PhaseMachine.DefaultFanValue()
ScoreEngine.FanValue 和 PhaseMachine.GetFanValue 之前各自维护
一份相同的硬编码 fallback 列表。改为 ScoreEngine 调用
PhaseMachine.DefaultFanValue() 单例消除重复。

硬编码审计结论: 所有影响变体适配的硬编码已全部修复,
剩余的是麻将基础规则(碰2/杠3/暗杠4/13/14张)和牌编码定义。
2026-07-04 21:51:40 +08:00

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 => "菊",
_ => "?"
};
}