YD_Event.Admin/YD_Event.Application/Scoring/EntryResultCalculationService.cs

202 lines
8.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using YD_Event.Application.Scoring.Dtos;
namespace YD_Event.Application.Scoring;
/// <summary>
/// 成绩聚合与排名计算服务。
/// 规定赛:T1=3 名创意编排裁判创意分的平均值,T2=3 名完成质量裁判完成分的平均值,
/// T3=主裁违例扣分(每项扣 5 分)合计,总分=T1+T2-T3,均四舍五入保留两位小数。
/// 排名:总分高者在前,总分相同依次比较 T2、T1,全部相同则并列,其后名次按 N+M 规则顺延。
/// </summary>
public static class EntryResultCalculationService
{
/// <summary>
/// 把某个出场的各裁判评分单聚合成一条成绩。judgeNames 用于在计算明细里带上裁判姓名,可为空。
/// </summary>
public static EntryAggregateResult Aggregate(ScoreRuleConfig config, List<EventJudgeScoreSheet> sheets,
IReadOnlyDictionary<long, string>? judgeNames = null)
{
var result = new EntryAggregateResult { Formula = BuildFormula(config) };
var total = 0m;
foreach (var role in config.RoleAggregations)
{
var symbol = new EntrySymbolScore
{
Symbol = role.Symbol, Label = role.Label, RoleType = role.RoleType,
Aggregate = role.Aggregate, Sign = role.Sign
};
var roleSheets = sheets
.Where(x => string.Equals(JudgeRoleSchemaService.NormalizeRoleType(x.JudgeRoleType), role.RoleType, StringComparison.OrdinalIgnoreCase))
.OrderBy(x => x.JudgeRole).ThenBy(x => x.Id)
.ToList();
foreach (var sheet in roleSheets)
{
symbol.Judges.Add(new EntryJudgeScore
{
ScoreSheetId = sheet.Id, RefereeId = sheet.RefereeId,
JudgeName = judgeNames != null && judgeNames.TryGetValue(sheet.RefereeId, out var name) ? name : null,
JudgeRole = sheet.JudgeRole, JudgeRoleType = sheet.JudgeRoleType,
Value = SheetValue(sheet, role.SourceField), IsDisqualified = sheet.IsDisqualified
});
}
// 该类型暂无裁判提交时按 0 分计入,成绩是否可用由调用方按提交进度判断。
symbol.Value = config.Round(Combine(symbol.Judges.Select(x => x.Value).ToList(), role.Aggregate));
result.Symbols.Add(symbol);
ApplyTargetField(result, role.TargetField, symbol.Value);
total += role.Sign * symbol.Value;
}
result.TotalScore = config.Round(total);
// 任一裁判判定取消资格即取消该出场成绩,取消资格后不计总分、不参与排名。
var disqualified = sheets.FirstOrDefault(x => x.IsDisqualified);
if (disqualified != null)
{
result.IsDisqualified = true;
result.DisqualifyReasonCode = disqualified.DisqualifyReasonCode;
result.TotalScore = 0m;
}
return result;
}
/// <summary>
/// 按规则的并列比较顺序计算名次,名次并列时其后名次按 N+M 规则顺延(两个并列第一,下一名为第三名)
/// </summary>
public static void AssignRanks(List<RankCandidate> candidates, ScoreRuleConfig config)
{
var fields = ResolveTieBreakers(config);
var ranked = new List<RankCandidate>();
foreach (var candidate in candidates)
{
candidate.Rank = 0;
if (candidate.IsRanked && !candidate.IsDisqualified) ranked.Add(candidate);
}
ranked.Sort((left, right) => Compare(left, right, fields));
var rank = 1;
var index = 0;
while (index < ranked.Count)
{
// 所有比较字段都相等的视为并列。
var tied = 1;
while (index + tied < ranked.Count && Compare(ranked[index], ranked[index + tied], fields) == 0) tied++;
for (var i = 0; i < tied; i++) ranked[index + i].Rank = rank;
index += tied;
rank += tied;
}
}
/// <summary>
/// 生成计分公式文本,如 T1 + T2 - T3
/// </summary>
public static string BuildFormula(ScoreRuleConfig config)
{
var text = new System.Text.StringBuilder();
foreach (var role in config.RoleAggregations)
{
if (text.Length == 0) text.Append(role.Sign < 0 ? "-" + role.Symbol : role.Symbol);
else text.Append(role.Sign < 0 ? " - " : " + ").Append(role.Symbol);
}
return text.ToString();
}
/// <summary>
/// 计算明细 JSON,落库到成绩的 CalculationJson,成绩复核与申诉时可完整还原计算过程
/// </summary>
public static string BuildCalculationJson(EntryAggregateResult result, ScoreRuleConfig config)
{
return JsonConvert.SerializeObject(new
{
ruleType = config.RuleType,
formula = result.Formula,
scoreDecimals = config.ScoreDecimals,
totalScore = result.TotalScore,
isDisqualified = result.IsDisqualified,
disqualifyReasonCode = result.DisqualifyReasonCode,
symbols = result.Symbols.Select(x => new
{
x.Symbol, x.Label, x.RoleType, x.Aggregate, x.Sign, x.Value, x.JudgeCount,
judges = x.Judges.Select(j => new { j.ScoreSheetId, j.RefereeId, j.JudgeName, j.JudgeRole, j.Value })
})
});
}
/// <summary>
/// 解析并列比较字段,字段名前加“-”表示该项越小越靠前
/// </summary>
private static List<(string Field, bool Ascending)> ResolveTieBreakers(ScoreRuleConfig config)
{
var fields = new List<(string, bool)>();
foreach (var raw in config.TieBreakers)
{
var text = (raw ?? string.Empty).Trim();
if (text.Length == 0) continue;
var ascending = text.StartsWith('-');
fields.Add((ascending ? text[1..] : text, ascending));
}
if (fields.Count == 0) fields.Add((nameof(RankCandidate.TotalScore), false));
return fields;
}
/// <summary>
/// 按比较字段顺序比较两条成绩,返回负数表示 left 名次更靠前
/// </summary>
private static int Compare(RankCandidate left, RankCandidate right, List<(string Field, bool Ascending)> fields)
{
foreach (var (field, ascending) in fields)
{
var compared = left.FieldValue(field).CompareTo(right.FieldValue(field));
if (compared == 0) continue;
return ascending ? compared : -compared;
}
return 0;
}
/// <summary>
/// 多名裁判之间的聚合。人数不固定时用平均值,因此裁判人数变化不影响计分口径。
/// </summary>
private static decimal Combine(List<decimal> values, string aggregate)
{
if (values.Count == 0) return 0m;
return aggregate switch
{
ScoreRuleConfig.AggregateSum => values.Sum(),
ScoreRuleConfig.AggregateMax => values.Max(),
ScoreRuleConfig.AggregateMin => values.Min(),
_ => values.Sum() / values.Count
};
}
/// <summary>
/// 按配置的字段名取评分单上的分值
/// </summary>
private static decimal SheetValue(EventJudgeScoreSheet sheet, string field) => field switch
{
nameof(EventJudgeScoreSheet.DifficultyScore) => sheet.DifficultyScore,
nameof(EventJudgeScoreSheet.PerformanceScore) => sheet.PerformanceScore,
nameof(EventJudgeScoreSheet.RequiredScore) => sheet.RequiredScore,
nameof(EventJudgeScoreSheet.DeductionScore) => sheet.DeductionScore,
nameof(EventJudgeScoreSheet.RequiredCompletedCount) => sheet.RequiredCompletedCount,
nameof(EventJudgeScoreSheet.RequiredMissingCount) => sheet.RequiredMissingCount,
nameof(EventJudgeScoreSheet.TotalScore) => sheet.TotalScore,
// 宁可报错也不能按 0 分静默计入,避免规则配置写错却算出一份看似正常的成绩。
_ => throw Oops.Bah($"计分规则配置的取值字段{field}无效")
};
/// <summary>
/// 把聚合结果写入成绩对应的分值字段
/// </summary>
private static void ApplyTargetField(EntryAggregateResult result, string field, decimal value)
{
switch (field)
{
case nameof(EventResult.DifficultyScore): result.DifficultyScore = value; break;
case nameof(EventResult.PerformanceScore): result.PerformanceScore = value; break;
case nameof(EventResult.RequiredScore): result.RequiredScore = value; break;
case nameof(EventResult.DeductionScore): result.DeductionScore = value; break;
default: throw Oops.Bah($"计分规则配置的成绩字段{field}无效");
}
}
}