/** * 规则包归一化。 * * `GET /api/rules/export` 返回裸 YAML(服务端 rules.Marshal),这里把解析出的 * unknown 收敛成 shared 的 RulePack 契约:补齐缺省值、丢弃未知字段。 * 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。 */ import { normalizeCountToTeam } from './channels' import { asRecord, emptyToNull, firstBool, firstNumber, firstNumberOrNull, firstString, isRecord, } from './internal' import type { ChannelBasis, ChannelScope, ChannelTemplate, EventOverride, EventType, RecordBonusLevel, RecordBonusMode, ResultType, RuleChannel, RulePack, RulePackManualAdjustment, RulePackMeta, RulePackRecordBonus, RulePackStandings, RuleProfile, ScoringProfileRule, TieBreakKey, TieMode, } from './types' const TIE_BREAK_KEYS: readonly TieBreakKey[] = [ 'points', 'gold', 'silver', 'bronze', 'teamCode', 'name', ] const DEFAULT_TIE_BREAK: TieBreakKey[] = ['points', 'gold', 'silver', 'bronze', 'teamCode'] const TIE_MODES: readonly TieMode[] = ['skip', 'average', 'higher'] const RECORD_BONUS_MODES: readonly RecordBonusMode[] = ['max', 'sum'] const SCOPES: readonly ChannelScope[] = ['athlete', 'team'] const BASES: readonly ChannelBasis[] = ['result', 'manual', 'aggregate'] const EVENT_TYPES: readonly EventType[] = ['individual', 'team', 'match', 'individual_team'] const RESULT_TYPES: readonly ResultType[] = ['time', 'distance', 'count', 'score', 'none'] export function normalizeRulePack(raw: unknown): RulePack | null { if (!isRecord(raw)) return null if (firstString(raw.format) !== 'scoreboard-rules') return null if (firstNumber(0, raw.formatVersion) !== 1) return null return { format: 'scoreboard-rules', formatVersion: 1, meta: normalizeMeta(raw.meta), standings: normalizeStandings(raw.standings), profiles: normalizeProfiles(raw.profiles), recordBonus: normalizeRecordBonus(raw.recordBonus), manualAdjustment: normalizeManualAdjustment(raw.manualAdjustment), channelTemplates: normalizeTemplates(raw.channelTemplates), eventOverrides: normalizeOverrides(raw.eventOverrides), } } function oneOf(value: unknown, allowed: readonly T[], fallback: T): T { return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback } function optionalOneOf(value: unknown, allowed: readonly T[]): T | undefined { return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : undefined } function normalizeMeta(raw: unknown): RulePackMeta { const rec = asRecord(raw) return { code: firstString(rec.code) ?? '', name: firstString(rec.name) ?? '', version: firstString(rec.version) ?? '', updatedAt: firstString(rec.updatedAt) ?? '', author: emptyToNull(firstString(rec.author)), note: emptyToNull(firstString(rec.note)), } } function normalizeStandings(raw: unknown): RulePackStandings { const rec = asRecord(raw) const tieBreakRaw = rec.tieBreak const tieBreak = Array.isArray(tieBreakRaw) ? tieBreakRaw.filter( (item): item is TieBreakKey => typeof item === 'string' && (TIE_BREAK_KEYS as readonly string[]).includes(item), ) : [] return { individualToTeamWeight: firstNumber(1, rec.individualToTeamWeight), tieBreak: tieBreak.length > 0 ? tieBreak : [...DEFAULT_TIE_BREAK], medalCountsTeamEvents: firstBool(true, rec.medalCountsTeamEvents), showIndividualBoard: firstBool(true, rec.showIndividualBoard), showIndividualMedals: firstBool(true, rec.showIndividualMedals), } } function normalizeProfiles(raw: unknown): RuleProfile[] { if (!Array.isArray(raw)) return [] const out: RuleProfile[] = [] for (const item of raw) { const rec = asRecord(item) const code = firstString(rec.code) if (code === null || code === '') continue out.push({ code, name: firstString(rec.name) ?? code, awardRanks: firstNumber(0, rec.awardRanks), lastPlaceZero: firstBool(false, rec.lastPlaceZero), tieMode: oneOf(rec.tieMode, TIE_MODES, 'skip'), recordBonusMode: optionalOneOf(rec.recordBonusMode, RECORD_BONUS_MODES), rules: normalizeScoringRules(rec.rules), }) } return out } function normalizeScoringRules(raw: unknown): ScoringProfileRule[] { if (!Array.isArray(raw)) return [] const out: ScoringProfileRule[] = [] for (const item of raw) { const rec = asRecord(item) const rank = firstNumberOrNull(rec.rank) const points = firstNumberOrNull(rec.points) if (rank === null || points === null) continue out.push({ rank, points }) } return out } function normalizeRecordBonus(raw: unknown): RulePackRecordBonus { const rec = asRecord(raw) return { enabled: firstBool(false, rec.enabled), mode: oneOf(rec.mode, RECORD_BONUS_MODES, 'max'), levels: normalizeLevels(rec.levels), } } function normalizeLevels(raw: unknown): RecordBonusLevel[] { if (!Array.isArray(raw)) return [] const out: RecordBonusLevel[] = [] for (const item of raw) { const rec = asRecord(item) const code = firstString(rec.code) if (code === null || code === '') continue out.push({ code, name: firstString(rec.name) ?? code, points: firstNumber(0, rec.points) }) } return out } function normalizeManualAdjustment(raw: unknown): RulePackManualAdjustment { const rec = asRecord(raw) return { enabled: firstBool(false, rec.enabled), reasonRequired: firstBool(true, rec.reasonRequired), } } function normalizeTemplates(raw: unknown): ChannelTemplate[] { if (!Array.isArray(raw)) return [] return raw.map((item) => { const rec = asRecord(item) const match = asRecord(rec.match) return { match: { eventType: optionalOneOf(match.eventType, EVENT_TYPES), resultType: optionalOneOf(match.resultType, RESULT_TYPES), sportCode: firstString(match.sportCode) ?? undefined, }, channels: normalizeChannels(rec.channels), } }) } function normalizeOverrides(raw: unknown): EventOverride[] { if (!Array.isArray(raw)) return [] const out: EventOverride[] = [] for (const item of raw) { const rec = asRecord(item) const eventCode = firstString(rec.eventCode) if (eventCode === null || eventCode === '') continue const override: EventOverride = { eventCode } const name = firstString(rec.name) if (name !== null) override.name = name if (typeof rec.scored === 'boolean') override.scored = rec.scored const multiplier = firstNumberOrNull(rec.multiplier) if (multiplier !== null) override.multiplier = multiplier // 三态:缺省表示不动该小项已有的奖牌开关,只有显式写了才落到覆盖对象上。 if (typeof rec.awardMedals === 'boolean') override.awardMedals = rec.awardMedals if (Array.isArray(rec.channels)) override.channels = normalizeChannels(rec.channels) out.push(override) } return out } function normalizeChannels(raw: unknown): RuleChannel[] { if (!Array.isArray(raw)) return [] const out: RuleChannel[] = [] for (const item of raw) { const rec = asRecord(item) const profile = firstString(rec.profile) if (profile === null || profile === '') continue const channel: RuleChannel = { scope: oneOf(rec.scope, SCOPES, 'athlete'), basis: oneOf(rec.basis, BASES, 'result'), profile, // 旧规则包不写这个字段,缺省按「计入单位总分」处理。 countToTeam: normalizeCountToTeam(rec.countToTeam), } // 两个奖牌开关同样是「缺省即开」;只有规则包显式写了才落到对象上, // 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。 if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals out.push(channel) } return out }