using YD_Event.Application.Scoring.Dtos; namespace YD_Event.Application.Scoring; /// /// 评分单计算服务。裁判端提交的分值一律在服务端按计分规则重算,客户端传来的合计值不参与落库。 /// public static class ScoreSheetCalculationService { private const decimal WheelRequiredCountPerElement = 4m; private const decimal WheelRequiredTotal = 16m; private static readonly string[] WheelRequiredElementCodes = { "REQ_STRENGTH_GYMNASTICS", "REQ_MULTIPLE_ROPE", "REQ_ROPE_MANIPULATION", "REQ_INTERACTION" }; /// /// 按项目类型判断评分项适用范围 /// public static string ResolveScope(ProjectTypeEnum projectType) => projectType is ProjectTypeEnum.TeamSpeed or ProjectTypeEnum.TeamFreestyle ? ScoreScope.Team : ScoreScope.Personal; /// /// 取某个裁判类型在本场次需要评的评分项 /// public static List ItemsOf(ScoringContext context, string judgeRoleType) => ResolveItems(context.RuleItems, judgeRoleType, context.Scope); /// /// 取某个裁判类型在当前项目范围下需要评的评分项 /// public static List ResolveItems(IEnumerable ruleItems, string judgeRoleType, string scope) { var matchedItems = ruleItems .Where(x => !x.DeleteMark) .Where(x => string.Equals(x.JudgeRoleType, judgeRoleType, StringComparison.OrdinalIgnoreCase)) .Where(x => MatchScope(x, scope)) .OrderBy(x => x.ItemType).ThenBy(x => x.Sort).ThenBy(x => x.Id) .ToList(); var isWheelRequiredRule = WheelRequiredElementCodes.All(code => matchedItems.Any(item => string.Equals(item.ItemCode, code, StringComparison.OrdinalIgnoreCase))); return matchedItems.Select(x => CreateDefinition(x, scope, isWheelRequiredRule)).ToList(); } /// /// 解析评分项定义。scope 为 personal 或 team,用于取对应的分值上限。 /// public static ScoreItemDefinition CreateDefinition(ScoreRuleItem item, string scope) => CreateDefinition(item, scope, false); private static ScoreItemDefinition CreateDefinition(ScoreRuleItem item, string scope, bool isWheelRequiredRule) { var definition = new ScoreItemDefinition { Item = item, MinValue = item.MinValue ?? 0m, MaxValue = item.MaxValue ?? 0m, StepValue = item.StepValue is > 0m ? item.StepValue!.Value : 1m }; JObject? extension = null; if (!string.IsNullOrWhiteSpace(item.ExtensionJson)) { // 扩展配置由人工维护,格式不合法时按无扩展配置处理,评分项本身仍然可用。 try { extension = JObject.Parse(item.ExtensionJson); } catch (JsonException) { extension = null; } } if (extension == null) { ApplyWheelRequiredElementCompatibility(item, definition, isWheelRequiredRule); return definition; } definition.InputMode = extension["inputMode"]?.ToString() ?? definition.InputMode; definition.UnitLabel = extension["unitLabel"]?.ToString(); definition.Remark = extension["remark"]?.ToString(); // 打分项的微调步长(0.5)优先于规则明细上的步长。 var fineTuneStep = ReadDecimal(extension["fineTuneStep"]); if (fineTuneStep is > 0m) definition.StepValue = fineTuneStep.Value; // 个人项目与集体项目的分值上限不同,按当前项目范围取值。 var scopedMax = ReadDecimal(extension["maxByScope"]?[scope]); if (scopedMax.HasValue) definition.MaxValue = scopedMax.Value; // 花样赛支持评分对象、表现维度、递增扣分、规定元素折算和难度聚合。 definition.IsAuto = ReadBool(extension["isAuto"]) ?? false; definition.SubjectCode = Normalize(extension["subjectCode"]); definition.SubjectName = Normalize(extension["subjectName"]); definition.DimensionCode = Normalize(extension["dimensionCode"]); definition.DimensionWeight = ReadDecimal(extension["dimensionWeight"]); definition.BaseValue = ReadDecimal(extension["baseValue"]); definition.AdjustSign = ReadInt(extension["adjustSign"]) ?? 0; definition.IsFailureCount = ReadBool(extension["isFailureCount"]) ?? false; definition.RequiredTarget = ReadDecimal(extension["requiredTarget"]); definition.RequiredAsCategory = ReadBool(extension["requiredAsCategory"]) ?? false; definition.DifficultyAggregate = Normalize(extension["difficultyAggregate"])?.ToLowerInvariant(); definition.DifficultyTopCount = ReadInt(extension["difficultyTopCount"]); if (extension["stepDeductions"] is JArray stepDeductions && stepDeductions.Count > 0) { definition.StepDeductions = stepDeductions .Select(x => ReadDecimal(x)).Where(x => x.HasValue).Select(x => x!.Value).ToList(); } ApplyWheelRequiredElementCompatibility(item, definition, isWheelRequiredRule); return definition; } /// /// 车轮跳包含四类规定元素,每类最多录入四个,合计规定数量为十六个。 /// private static void ApplyWheelRequiredElementCompatibility(ScoreRuleItem item, ScoreItemDefinition definition, bool isWheelRequiredRule) { if (!isWheelRequiredRule) return; if (WheelRequiredElementCodes.Contains(item.ItemCode, StringComparer.OrdinalIgnoreCase)) { definition.MaxValue = WheelRequiredCountPerElement; definition.StepValue = 1m; definition.RequiredAsCategory = false; return; } if (!string.Equals(item.ItemCode, "REQUIRED_MISSING", StringComparison.OrdinalIgnoreCase)) return; definition.MaxValue = WheelRequiredTotal; definition.RequiredTarget = WheelRequiredTotal; } /// /// 读取扩展配置里的文本,空白按未配置处理 /// private static string? Normalize(JToken? node) { var text = node?.ToString()?.Trim(); return string.IsNullOrEmpty(text) ? null : text; } /// /// 读取扩展配置里的整数,非法值按未配置处理 /// private static int? ReadInt(JToken? node) { try { return node?.Value(); } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; } } /// /// 读取扩展配置里的布尔值,非法值按未配置处理 /// private static bool? ReadBool(JToken? node) { try { return node?.Value(); } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; } } /// /// 读取扩展配置里的数值,非数字等非法值按未配置处理 /// private static decimal? ReadDecimal(JToken? node) { try { return node?.Value(); } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return null; } } /// /// 判断本项是否适用于当前项目范围。扩展配置里的 scopes 列出了适用范围,未配置表示都适用。 /// public static bool MatchScope(ScoreRuleItem item, string scope) { if (string.IsNullOrWhiteSpace(item.ExtensionJson)) return true; JObject extension; try { extension = JObject.Parse(item.ExtensionJson); } catch (JsonException) { return true; } if (extension["scopes"] is not JArray scopes || scopes.Count == 0) return true; return scopes.Any(x => string.Equals(x.ToString(), scope, StringComparison.OrdinalIgnoreCase)); } /// /// 按计分规则计算一张评分单。 /// requireAll 为 true(正式提交)时校验必填项与取值范围;为 false(保存草稿)时允许留空,只算已录入的部分。 /// 花样赛的表现维度分、失误联动项、规定元素缺失数一律由服务端生成,客户端传来的自动项不参与计算。 /// public static ScoreSheetCalculationResult Calculate(List definitions, List inputs, ScoreRuleConfig config, bool requireAll) { // 同步主裁需要分别录入两个等级区间,未分级数量不能自动归入任一等级。 if ((config.RuleType.ToUpperInvariant() is "FREESTYLE_DOUBLE_SYNC" or "FREESTYLE_QUAD_SYNC") && definitions.Any(x => x.Item.JudgeRoleType == "T") && (!definitions.Any(x => x.ItemCode == "REQ_INTERACTION_N3_PLUS") || !definitions.Any(x => x.ItemCode == "REQ_INTERACTION_N2_MINUS"))) throw Oops.Bah("同步花样互动配合规则尚未更新,请先刷新评分任务并使用分级录入"); var inputMap = inputs .Where(x => !string.IsNullOrWhiteSpace(x.ItemCode)) .GroupBy(x => x.ItemCode.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => x.Last(), StringComparer.OrdinalIgnoreCase); var unknown = inputMap.Keys.FirstOrDefault(code => definitions.All(d => !string.Equals(d.ItemCode, code, StringComparison.OrdinalIgnoreCase))); if (unknown != null) throw Oops.Bah($"评分项{unknown}不属于当前裁判的评分表"); var result = new ScoreSheetCalculationResult(); // 自动项由服务端重建,先按录入项算出各维度小计。 var manual = definitions.Where(x => !x.IsAuto).ToList(); foreach (var definition in manual) { inputMap.TryGetValue(definition.ItemCode, out var input); var line = BuildLine(definition, input, requireAll); if (line == null) continue; // 难度裁判端的单项小计与规则展示精度保持一致。 if (definition.Item.ScoreDimension == ScoreDimensionCode.Difficulty) line.Subtotal = config.Round(line.Subtotal); result.Lines.Add(line); if (definition.IsDisqualifyReason) { // 取消资格原因勾选任意一项即判定取消资格,多选时取排序最前的一项作为主原因。 if (line.CountValue is > 0m && !result.IsDisqualified) { result.IsDisqualified = true; result.DisqualifyReasonCode = definition.ItemCode; result.DisqualifyReasonName = definition.Item.ItemName; } continue; } // 表现维度的加减项、递增扣分项、规定元素计数项各有专门算法,不按小计直接累加。 if (definition.IsFailureCount) continue; if (definition.DimensionCode != null && definition.AdjustSign != 0) continue; if (definition.StepDeductions is { Count: > 0 }) continue; if (definition.Item.ItemType == ScoreItemTypeCode.RequiredElement) continue; switch (definition.Item.ScoreDimension) { case ScoreDimensionCode.Difficulty: result.DifficultyScore += line.Subtotal; break; case ScoreDimensionCode.Performance: result.PerformanceScore += line.Subtotal; break; case ScoreDimensionCode.Required: result.RequiredScore += line.Subtotal; break; case ScoreDimensionCode.Deduction: result.DeductionScore += line.Subtotal; break; } } ApplyDifficultyAggregate(definitions, result, config); ApplyPerformanceDimensions(definitions, result, config); ApplyStepDeductions(definitions, result, config); ApplyRequiredElements(definitions, result, config); // 花样赛因子在跨裁判汇总时统一舍入;规定赛沿用原有精度。 if (!config.IsFactorTotal) { result.DifficultyScore = config.Round(result.DifficultyScore); result.PerformanceScore = config.Round(result.PerformanceScore); result.RequiredScore = config.Round(result.RequiredScore); result.DeductionScore = config.Round(result.DeductionScore); } // 难度裁判的规定元素完成量仅作为独立记录,不能计入本裁判难度分。 // 非因子模式保留规定赛的完成分汇总方式。 var isDifficultySheet = definitions.Any(x => !x.IsAuto && x.Item.ItemType != ScoreItemTypeCode.RequiredElement && (x.Item.ItemType == ScoreItemTypeCode.DifficultyCount || x.Item.ScoreDimension == ScoreDimensionCode.Difficulty)); var requiredContribution = isDifficultySheet ? 0m : result.RequiredScore; // 花样赛最终 R 仍由跨裁判聚合按 D×(1+P)×Q×M 计算。 result.TotalScore = config.IsFactorTotal ? config.Round(result.DifficultyScore + result.PerformanceScore) : config.Round(result.DifficultyScore + result.PerformanceScore + requiredContribution - result.DeductionScore); return result; } /// /// 难度分聚合。交互绳跳绳者按全部动作取平均、摇绳者取最高 20 个动作的平均, /// 其余项目(个人花样、车轮跳花样)保持数量 × 等级分值累加。 /// private static void ApplyDifficultyAggregate(List definitions, ScoreSheetCalculationResult result, ScoreRuleConfig config) { var mode = definitions.Select(x => x.DifficultyAggregate).FirstOrDefault(x => !string.IsNullOrEmpty(x)); if (mode == null) return; var difficultyLines = result.Lines .Where(x => x.ItemType != ScoreItemTypeCode.RequiredElement && x.ScoreDimension == ScoreDimensionCode.Difficulty && x.CountValue is > 0m).ToList(); var actionCount = difficultyLines.Sum(x => x.CountValue!.Value); if (actionCount <= 0m) { result.DifficultyScore = 0m; return; } if (mode == DifficultyAggregateMode.Top) { // 取难度等级最高的 N 个动作:按单位分值降序逐级取用,分母固定为规则配置的动作数。 var topCount = definitions.Select(x => x.DifficultyTopCount).FirstOrDefault(x => x is > 0) ?? 20; var remaining = (decimal)topCount; var selected = 0m; foreach (var line in difficultyLines.OrderByDescending(x => x.UnitValue ?? 0m)) { if (remaining <= 0m) break; var taken = Math.Min(remaining, line.CountValue!.Value); selected += taken * (line.UnitValue ?? 0m); remaining -= taken; } result.DifficultyScore = selected / topCount; result.Steps.Add(new ScoreSheetStep { Code = "DIFFICULTY_TOP", Label = $"取最高{topCount}个动作的难度均值", Formula = $"Σ(入选动作分值) ÷ {topCount}", Value = config.Round(result.DifficultyScore) }); return; } result.DifficultyScore = result.DifficultyScore / actionCount; result.Steps.Add(new ScoreSheetStep { Code = "DIFFICULTY_AVG", Label = "按动作总数取难度均值", Formula = $"Σ(数量 × 等级分值) ÷ {Trim(actionCount)}", Value = config.Round(result.DifficultyScore) }); } /// /// 表现维度计算。失误在每个维度只扣一次,同时独立提交给综合扣分 M; /// 每个维度 = 基准分 + 人工加 - 人工减 - 失误次数,限制在上下限内, /// 再按各维度权重加权求和得到本裁判的表现分。失误自动减分作为自动项落明细,裁判不可直接修改。 /// private static void ApplyPerformanceDimensions(List definitions, ScoreSheetCalculationResult result, ScoreRuleConfig config) { var dimensions = definitions .Where(x => x.DimensionCode != null && x.DimensionWeight is > 0m && x.IsAuto) .ToList(); if (dimensions.Count == 0) return; // 每个表现维度只按一次失误扣减;同一失误数量另用于跨裁判汇总综合扣分 M。 var failure = definitions.FirstOrDefault(x => x.IsFailureCount); var failureCount = failure == null ? 0m : result.Lines.FirstOrDefault(x => string.Equals(x.ItemCode, failure.ItemCode, StringComparison.OrdinalIgnoreCase))?.CountValue ?? 0m; var performanceFailureCount = failureCount; var hasPerformanceMarks = definitions.Any(x => !x.IsAuto && x.AdjustSign != 0 && result.Lines.Any(line => string.Equals(line.ItemCode, x.ItemCode, StringComparison.OrdinalIgnoreCase) && line.CountValue is > 0m)); var weighted = 0m; foreach (var dimension in dimensions) { var adjustItems = definitions .Where(x => !x.IsAuto && x.AdjustSign != 0 && string.Equals(x.DimensionCode, dimension.DimensionCode, StringComparison.OrdinalIgnoreCase)) .ToList(); var raw = dimension.BaseValue ?? 0m; foreach (var adjust in adjustItems) { var count = result.Lines .FirstOrDefault(x => string.Equals(x.ItemCode, adjust.ItemCode, StringComparison.OrdinalIgnoreCase))?.CountValue ?? 0m; raw += adjust.AdjustSign * count * (adjust.Item.UnitValue ?? 1m); } raw -= performanceFailureCount * (dimension.Item.UnitValue ?? 1m); // 维度分限制在规则给出的上下限内(规程为 0~24)并取整。 var value = !hasPerformanceMarks && performanceFailureCount == 0m ? 0m : Math.Clamp(Math.Round(raw, 0, MidpointRounding.AwayFromZero), dimension.MinValue, dimension.MaxValue); weighted += value * dimension.DimensionWeight!.Value; if (performanceFailureCount > 0m) { result.Lines.Add(new ScoreSheetLine { ScoreRuleItemId = dimension.Item.Id, ItemCode = $"{dimension.DimensionCode}_FAILURE_MINUS", ItemName = $"{dimension.Item.ItemName}(失误联动减数量)", ItemType = dimension.Item.ItemType, ScoreDimension = dimension.Item.ScoreDimension, CountValue = performanceFailureCount, UnitValue = dimension.Item.UnitValue ?? 1m, Subtotal = -performanceFailureCount * (dimension.Item.UnitValue ?? 1m), Sort = dimension.Item.Sort, IsAutoGenerated = true, ParentItemCode = failure?.ItemCode, SubjectCode = dimension.SubjectCode }); } result.Lines.Add(new ScoreSheetLine { ScoreRuleItemId = dimension.Item.Id, ItemCode = dimension.ItemCode, ItemName = dimension.Item.ItemName, ItemType = dimension.Item.ItemType, ScoreDimension = dimension.Item.ScoreDimension, InputValue = value, UnitValue = dimension.Item.UnitValue, Subtotal = value, Sort = dimension.Item.Sort, IsAutoGenerated = true, SubjectCode = dimension.SubjectCode, ParentItemCode = performanceFailureCount > 0m ? failure?.ItemCode : null }); result.Steps.Add(new ScoreSheetStep { Code = $"DIM_{dimension.DimensionCode}", Label = dimension.Item.ItemName, Formula = !hasPerformanceMarks && performanceFailureCount == 0m ? "尚未录入表现加减或失误,表现分为 0" : $"{Trim(dimension.BaseValue ?? 0m)} + 人工加 - 人工减 - 失误{Trim(performanceFailureCount)}次", Value = value }); } result.PerformanceScore = weighted; result.Steps.Add(new ScoreSheetStep { Code = "PERFORMANCE_WEIGHTED", Label = "表现分(各维度加权求和)", Formula = "Σ(维度分 × 权重)", Value = config.Round(weighted) }); } /// /// 递增扣分。主裁失误第 1 次扣 0.05、第 2 次 0.075、第 3 次起每次 0.1,序列用尽后按最后一档重复。 /// private static void ApplyStepDeductions(List definitions, ScoreSheetCalculationResult result, ScoreRuleConfig config) { foreach (var definition in definitions.Where(x => x.StepDeductions is { Count: > 0 })) { var line = result.Lines .FirstOrDefault(x => string.Equals(x.ItemCode, definition.ItemCode, StringComparison.OrdinalIgnoreCase)); var count = (int)(line?.CountValue ?? 0m); if (count <= 0) continue; var steps = definition.StepDeductions!; var deduction = 0m; for (var i = 0; i < count; i++) deduction += steps[Math.Min(i, steps.Count - 1)]; if (line != null) line.Subtotal = deduction; result.DeductionScore += deduction; result.Steps.Add(new ScoreSheetStep { Code = $"STEP_{definition.ItemCode}", Label = definition.Item.ItemName, Formula = string.Join(" + ", Enumerable.Range(0, count).Select(i => Trim(steps[Math.Min(i, steps.Count - 1)]))), Value = config.IsFactorTotal ? deduction : config.Round(deduction) }); } } /// /// 规定元素完成量与缺失量。 /// 按数量折算时(个人花样)3 级及以上按 1 计、2 级及以下按 0.5 计; /// 按类别计数时(如表演赛)每个类别只要录入数量大于 0 就计一类; /// 车轮跳花样按实际完成数累加,4 类元素每类上限 4 个。 /// 缺失量 = max(0, 目标数 - 完成量),作为自动项落明细,供跨裁判聚合按 Q = 1 - 系数 × 缺失量 计算。 /// private static void ApplyRequiredElements(List definitions, ScoreSheetCalculationResult result, ScoreRuleConfig config) { var elements = definitions .Where(x => !x.IsAuto && x.Item.ItemType == ScoreItemTypeCode.RequiredElement).ToList(); if (elements.Count == 0) return; var completed = 0m; foreach (var element in elements) { var line = result.Lines .FirstOrDefault(x => string.Equals(x.ItemCode, element.ItemCode, StringComparison.OrdinalIgnoreCase)); if (line == null) continue; var count = line.CountValue ?? 0m; // 折算系数写在评分项的单位分值上:个人花样 3 级及以上为 1、2 级及以下为 0.5;按类别计数时录入即算一类。 line.Subtotal = element.RequiredAsCategory ? (count > 0m ? 1m : 0m) : count * (element.Item.UnitValue ?? 1m); completed += line.Subtotal; } result.RequiredCompletedCount = completed; result.RequiredScore = completed; var missingItem = definitions.FirstOrDefault(x => x.IsAuto && x.RequiredTarget.HasValue); if (missingItem == null) return; var target = missingItem.RequiredTarget!.Value; var missing = Math.Max(0m, target - completed); result.RequiredMissingCount = missing; result.Lines.Add(new ScoreSheetLine { ScoreRuleItemId = missingItem.Item.Id, ItemCode = missingItem.ItemCode, ItemName = missingItem.Item.ItemName, ItemType = missingItem.Item.ItemType, ScoreDimension = missingItem.Item.ScoreDimension, InputValue = missing, CountValue = missing, Subtotal = 0m, Sort = missingItem.Item.Sort, IsAutoGenerated = true }); result.Steps.Add(new ScoreSheetStep { Code = "REQUIRED_MISSING", Label = "规定元素缺失数", Formula = $"max(0, {Trim(target)} - {Trim(completed)})", Value = config.Round(missing) }); } /// /// 计算明细 JSON,落库到评分单的 CalculationJson 便于复核与追溯 /// public static string BuildCalculationJson(ScoreSheetCalculationResult result, string judgeRole, string judgeRoleType, string scope) { return JsonConvert.SerializeObject(new { judgeRole, judgeRoleType, scope, difficultyScore = result.DifficultyScore, performanceScore = result.PerformanceScore, requiredScore = result.RequiredScore, deductionScore = result.DeductionScore, totalScore = result.TotalScore, isDisqualified = result.IsDisqualified, disqualifyReasonCode = result.DisqualifyReasonCode, requiredCompletedCount = result.RequiredCompletedCount, requiredMissingCount = result.RequiredMissingCount, lines = result.Lines.Select(x => new { x.ItemCode, x.ItemName, x.ItemType, x.ScoreDimension, x.CountValue, x.InputValue, x.UnitValue, x.Subtotal, x.SubjectCode, x.IsAutoGenerated, x.ParentItemCode }), steps = result.Steps.Select(x => new { x.Code, x.Label, x.Formula, x.Value }) }); } /// /// 计算单个评分项,未录入且非必填时返回 null(不落明细) /// private static ScoreSheetLine? BuildLine(ScoreItemDefinition definition, ScoreItemInputValue? input, bool requireAll) { var item = definition.Item; var line = new ScoreSheetLine { ScoreRuleItemId = item.Id, ItemCode = item.ItemCode, ItemName = item.ItemName, ItemType = item.ItemType, ScoreDimension = item.ScoreDimension, UnitValue = item.UnitValue, Sort = item.Sort, SubjectCode = definition.SubjectCode }; switch (item.CalcMode) { case ScoreCalcMode.DirectValue: { var value = input?.InputValue; if (!value.HasValue) { if (requireAll && item.IsRequired) throw Oops.Bah($"请先给“{item.ItemName}”打分"); if (!requireAll) return null; value = item.DefaultValue ?? definition.MinValue; } EnsureRange(definition, value.Value, "分值"); EnsureStep(definition, value.Value); line.InputValue = value.Value; line.Subtotal = value.Value; return line; } case ScoreCalcMode.CountTimesUnit: { if (requireAll && item.IsRequired && input?.CountValue == null) throw Oops.Bah($"请先录入“{item.ItemName}”的数量(无完成动作请填0)"); var count = input?.CountValue ?? (requireAll ? item.DefaultValue ?? 0m : (decimal?)null); if (!count.HasValue) return null; if (count.Value < 0m) throw Oops.Bah($"“{item.ItemName}”的次数不能为负数"); if (count.Value != Math.Truncate(count.Value)) throw Oops.Bah($"“{item.ItemName}”的次数必须是整数"); EnsureRange(definition, count.Value, "次数"); line.CountValue = count.Value; line.Subtotal = count.Value * (item.UnitValue ?? 0m); return line; } default: { // 取消资格原因等仅记录项:未勾选时不落明细。 var selected = input?.CountValue ?? 0m; if (selected <= 0m) return null; line.CountValue = 1m; line.Subtotal = 0m; return line; } } } /// /// 校验取值是否落在规则给出的上下限内。规则未配置有效上限(上限不大于下限)时只校验下限, /// 避免因漏配 MaxValue 把所有正常分值都判成超出范围。 /// private static void EnsureRange(ScoreItemDefinition definition, decimal value, string label) { if (definition.MaxValue <= definition.MinValue) { if (value < definition.MinValue) throw Oops.Bah($"“{definition.Item.ItemName}”的{label}不能小于 {Trim(definition.MinValue)}"); return; } if (value < definition.MinValue || value > definition.MaxValue) throw Oops.Bah($"“{definition.Item.ItemName}”的{label}应在 {Trim(definition.MinValue)} ~ {Trim(definition.MaxValue)} 之间"); } /// /// 校验打分是否符合录入步长(规定赛支持 0.5 微调) /// private static void EnsureStep(ScoreItemDefinition definition, decimal value) { if (definition.StepValue <= 0m) return; var offset = value - definition.MinValue; if (offset % definition.StepValue != 0m) throw Oops.Bah($"“{definition.Item.ItemName}”的分值需按 {Trim(definition.StepValue)} 分为单位录入"); } /// /// 去掉小数末尾多余的 0,用于错误提示 /// private static string Trim(decimal value) => value == Math.Truncate(value) ? ((long)value).ToString() : value.ToString("0.##"); }