527 lines
15 KiB
Go
527 lines
15 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。
|
|
const rankEpsilon = 1e-9
|
|
|
|
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
|
|
type ScoreOptions struct {
|
|
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
|
|
// 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。
|
|
UnionAttribution UnionAttribution
|
|
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
|
|
IncludeDrafts bool
|
|
}
|
|
|
|
// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。
|
|
type EventScoreInput struct {
|
|
Event Event
|
|
Category EventCategory
|
|
Channels []ScoreChannel
|
|
Profiles map[int64]ScoringProfile
|
|
Entries []Entry
|
|
Results []Result
|
|
RecordBonus *RecordBonusConfig
|
|
RecordBreaks []RecordBreak
|
|
Options ScoreOptions
|
|
}
|
|
|
|
// EventScore 是一次小项计分的结果。
|
|
type EventScore struct {
|
|
Ledger []LedgerEntry
|
|
Ranked []RankedResult
|
|
}
|
|
|
|
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
|
|
// Points 尚未按联队份额拆分、也未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
|
|
type RankedResult struct {
|
|
EntryID int64
|
|
ResultID int64
|
|
EventID int64
|
|
CategoryID int64
|
|
ChannelID int64
|
|
Scope Scope
|
|
Basis Basis
|
|
AthleteID int64
|
|
AthleteName string
|
|
TeamID int64
|
|
Shares []TeamShare
|
|
Rank int
|
|
Points float64
|
|
ResultValue *float64
|
|
Status ResultStatus
|
|
}
|
|
|
|
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
|
|
// 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。
|
|
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
|
|
func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
|
var out EventScore
|
|
rankOnly := len(in.Channels) == 0
|
|
channels := sortedChannels(in.Channels)
|
|
if rankOnly {
|
|
channels = []ScoreChannel{rankOnlyChannel(in)}
|
|
}
|
|
|
|
for _, ch := range channels {
|
|
// 不计分时不需要积分表:既不查模板,也不可能算出非零分。
|
|
profile := ScoringProfile{}
|
|
if in.Event.Scored && !rankOnly {
|
|
p, ok := in.Profiles[ch.ProfileID]
|
|
if !ok {
|
|
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
|
|
}
|
|
profile = p
|
|
}
|
|
ranked, err := scoreChannel(in, ch, profile)
|
|
if err != nil {
|
|
return EventScore{}, err
|
|
}
|
|
if !in.Event.Scored {
|
|
for i := range ranked {
|
|
ranked[i].Points = 0
|
|
}
|
|
}
|
|
out.Ranked = append(out.Ranked, ranked...)
|
|
if in.Event.Scored {
|
|
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
|
|
}
|
|
}
|
|
|
|
if !in.Event.Scored {
|
|
return out, nil
|
|
}
|
|
bonus, err := recordBonusLedger(in)
|
|
if err != nil {
|
|
return EventScore{}, err
|
|
}
|
|
out.Ledger = append(out.Ledger, bonus...)
|
|
return out, nil
|
|
}
|
|
|
|
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
|
|
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
|
|
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
|
|
basis := BasisResult
|
|
if in.Event.ResultMode == ResultModeManual {
|
|
basis = BasisManual
|
|
}
|
|
scope := ScopeAthlete
|
|
if in.Event.EventType == EventTypeTeam || in.Event.EventType == EventTypeMatch {
|
|
scope = ScopeTeam
|
|
}
|
|
return ScoreChannel{EventID: in.Event.ID, Scope: scope, Basis: basis}
|
|
}
|
|
|
|
// sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。
|
|
func sortedChannels(channels []ScoreChannel) []ScoreChannel {
|
|
out := append([]ScoreChannel(nil), channels...)
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].SortOrder != out[j].SortOrder {
|
|
return out[i].SortOrder < out[j].SortOrder
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
func scoreChannel(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
|
switch ch.Basis {
|
|
case BasisResult:
|
|
return scoreByResult(in, ch, profile)
|
|
case BasisManual:
|
|
return scoreByManual(in, ch, profile)
|
|
case BasisAggregate:
|
|
return scoreByAggregate(in, ch, profile)
|
|
default:
|
|
return nil, fmt.Errorf("%w: %q", ErrUnknownBasis, ch.Basis)
|
|
}
|
|
}
|
|
|
|
// candidate 是排名过程的中间态:一条可参与排名的报名项 + 它的比较值。
|
|
type candidate struct {
|
|
entry Entry
|
|
result Result
|
|
subject int64 // 聚合通道的排名主体(单位或运动员)
|
|
value float64 // 排序用比较值
|
|
rankIn int // manual 通道录入的名次
|
|
}
|
|
|
|
// collectCandidates 过滤出本通道真正参与排名的报名项:
|
|
// 只取已发布、状态正常、且带有本通道所需数据的成绩。
|
|
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
|
|
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
|
|
return nil, ErrCompareRequired
|
|
}
|
|
if ch.Basis == BasisAggregate && in.Event.Compare == CompareNone {
|
|
return nil, ErrCompareRequired
|
|
}
|
|
|
|
byEntry := make(map[int64]Result, len(in.Results))
|
|
for _, r := range in.Results {
|
|
byEntry[r.EntryID] = r
|
|
}
|
|
|
|
out := make([]candidate, 0, len(in.Entries))
|
|
for _, e := range in.Entries {
|
|
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
|
|
continue
|
|
}
|
|
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
|
continue
|
|
}
|
|
if !e.Status.rankable() {
|
|
continue
|
|
}
|
|
r, ok := byEntry[e.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
|
|
continue
|
|
}
|
|
if !r.Status.rankable() {
|
|
continue
|
|
}
|
|
|
|
c := candidate{entry: e, result: r}
|
|
switch ch.Basis {
|
|
case BasisManual:
|
|
if r.Rank <= 0 {
|
|
continue
|
|
}
|
|
c.rankIn = r.Rank
|
|
default: // result / aggregate 都按成绩值排序
|
|
if r.ResultValue == nil {
|
|
continue
|
|
}
|
|
c.value = *r.ResultValue
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
|
cands, err := collectCandidates(in, ch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sortCandidatesByValue(cands, in.Event.Compare)
|
|
return rankGroups(in, ch, profile, groupByValue(cands)), nil
|
|
}
|
|
|
|
func scoreByManual(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
|
cands, err := collectCandidates(in, ch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(cands, func(i, j int) bool {
|
|
if cands[i].rankIn != cands[j].rankIn {
|
|
return cands[i].rankIn < cands[j].rankIn
|
|
}
|
|
return cands[i].entry.ID < cands[j].entry.ID
|
|
})
|
|
return rankGroups(in, ch, profile, groupByManualRank(cands)), nil
|
|
}
|
|
|
|
// tieGroup 是一组并列的参赛者;startRank 是它们共同展示的名次。
|
|
type tieGroup struct {
|
|
startRank int
|
|
members []candidate
|
|
}
|
|
|
|
// groupByValue 按成绩值分组:值相同即并列,名次按「占位顺推」计算(skip 口径)。
|
|
func groupByValue(cands []candidate) []tieGroup {
|
|
var groups []tieGroup
|
|
pos := 1
|
|
for i := 0; i < len(cands); {
|
|
j := i + 1
|
|
for j < len(cands) && nearlyEqual(cands[j].value, cands[i].value) {
|
|
j++
|
|
}
|
|
groups = append(groups, tieGroup{startRank: pos, members: cands[i:j]})
|
|
pos += j - i
|
|
i = j
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// groupByManualRank 按录入名次分组:录入名次就是展示名次,不做顺推改写。
|
|
func groupByManualRank(cands []candidate) []tieGroup {
|
|
var groups []tieGroup
|
|
for i := 0; i < len(cands); {
|
|
j := i + 1
|
|
for j < len(cands) && cands[j].rankIn == cands[i].rankIn {
|
|
j++
|
|
}
|
|
groups = append(groups, tieGroup{startRank: cands[i].rankIn, members: cands[i:j]})
|
|
i = j
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// rankGroups 给每组分配积分并产出名次明细。
|
|
func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult {
|
|
lastRank := 0
|
|
for _, g := range groups {
|
|
if r := g.startRank + len(g.members) - 1; r > lastRank {
|
|
lastRank = r
|
|
}
|
|
}
|
|
|
|
var out []RankedResult
|
|
for _, g := range groups {
|
|
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
|
|
for _, c := range g.members {
|
|
out = append(out, rankedResult(in, ch, c, g.startRank, pts))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// groupPoints 计算一组并列者拿到的分。
|
|
// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。
|
|
func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 {
|
|
if startRank <= 0 || size <= 0 {
|
|
return 0
|
|
}
|
|
// lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理,
|
|
// 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。
|
|
if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 {
|
|
return 0
|
|
}
|
|
|
|
switch profile.tieMode() {
|
|
case TieAverage:
|
|
var sum float64
|
|
for r := startRank; r < startRank+size; r++ {
|
|
p, _ := basePoints(profile, r, ev, cat)
|
|
sum += p
|
|
}
|
|
return sum / float64(size)
|
|
case TieHigher:
|
|
best := 0.0
|
|
for r := startRank; r < startRank+size; r++ {
|
|
if p, ok := basePoints(profile, r, ev, cat); ok && p > best {
|
|
best = p
|
|
}
|
|
}
|
|
return best
|
|
default: // TieSkip
|
|
p, _ := basePoints(profile, startRank, ev, cat)
|
|
return p
|
|
}
|
|
}
|
|
|
|
// basePoints 查表得分并乘项目权重、组别权重;超出录取名次或表中无名次一律 0。
|
|
func basePoints(profile ScoringProfile, rank int, ev Event, cat EventCategory) (float64, bool) {
|
|
if rank <= 0 {
|
|
return 0, false
|
|
}
|
|
if profile.AwardRanks > 0 && rank > profile.AwardRanks {
|
|
return 0, false
|
|
}
|
|
p, ok := profile.pointsAt(rank)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return p * ev.multiplier() * cat.multiplier(), true
|
|
}
|
|
|
|
func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) RankedResult {
|
|
rr := RankedResult{
|
|
EntryID: c.entry.ID,
|
|
ResultID: c.result.ID,
|
|
EventID: in.Event.ID,
|
|
CategoryID: in.Category.ID,
|
|
ChannelID: ch.ID,
|
|
Scope: ch.Scope,
|
|
Basis: ch.Basis,
|
|
AthleteID: c.entry.AthleteID,
|
|
AthleteName: c.entry.AthleteName,
|
|
TeamID: c.entry.primaryTeamID(),
|
|
Shares: c.entry.resolvedShares(in.Options.UnionAttribution),
|
|
Rank: rank,
|
|
Points: points,
|
|
Status: c.result.Status,
|
|
}
|
|
if c.result.ResultValue != nil {
|
|
v := *c.result.ResultValue
|
|
rr.ResultValue = &v
|
|
}
|
|
return rr
|
|
}
|
|
|
|
// scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
|
|
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
|
cands, err := collectCandidates(in, ch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type bucket struct {
|
|
subject int64
|
|
entry Entry
|
|
total float64
|
|
}
|
|
order := make([]int64, 0)
|
|
buckets := make(map[int64]*bucket)
|
|
for _, c := range cands {
|
|
subject := c.entry.TeamID
|
|
if ch.Scope == ScopeAthlete {
|
|
subject = c.entry.AthleteID
|
|
}
|
|
if subject == 0 {
|
|
continue
|
|
}
|
|
b, ok := buckets[subject]
|
|
if !ok {
|
|
b = &bucket{subject: subject, entry: c.entry}
|
|
buckets[subject] = b
|
|
order = append(order, subject)
|
|
}
|
|
b.total += c.value
|
|
}
|
|
|
|
agg := make([]candidate, 0, len(order))
|
|
for _, id := range order {
|
|
b := buckets[id]
|
|
c := candidate{entry: b.entry, subject: b.subject, value: b.total}
|
|
c.entry.ID = 0 // 聚合结果不对应单条报名项
|
|
agg = append(agg, c)
|
|
}
|
|
sortCandidatesByValue(agg, in.Event.Compare)
|
|
|
|
groups := groupByValue(agg)
|
|
lastRank := 0
|
|
for _, g := range groups {
|
|
if r := g.startRank + len(g.members) - 1; r > lastRank {
|
|
lastRank = r
|
|
}
|
|
}
|
|
|
|
var out []RankedResult
|
|
for _, g := range groups {
|
|
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category)
|
|
for _, c := range g.members {
|
|
rr := RankedResult{
|
|
EventID: in.Event.ID,
|
|
CategoryID: in.Category.ID,
|
|
ChannelID: ch.ID,
|
|
Scope: ch.Scope,
|
|
Basis: ch.Basis,
|
|
TeamID: c.entry.primaryTeamID(),
|
|
Rank: g.startRank,
|
|
Points: pts,
|
|
}
|
|
if ch.Scope == ScopeTeam {
|
|
rr.TeamID = c.subject
|
|
rr.Shares = []TeamShare{{TeamID: c.subject, Weight: 1}}
|
|
} else {
|
|
rr.AthleteID = c.subject
|
|
rr.AthleteName = c.entry.AthleteName
|
|
rr.Shares = c.entry.resolvedShares(in.Options.UnionAttribution)
|
|
}
|
|
v := c.value
|
|
rr.ResultValue = &v
|
|
out = append(out, rr)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func sortCandidatesByValue(cands []candidate, compare CompareMode) {
|
|
sort.SliceStable(cands, func(i, j int) bool {
|
|
if nearlyEqual(cands[i].value, cands[j].value) {
|
|
return cands[i].entry.ID < cands[j].entry.ID
|
|
}
|
|
if compare == CompareDesc {
|
|
return cands[i].value > cands[j].value
|
|
}
|
|
return cands[i].value < cands[j].value
|
|
})
|
|
}
|
|
|
|
func nearlyEqual(a, b float64) bool {
|
|
return math.Abs(a-b) <= rankEpsilon
|
|
}
|
|
|
|
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位,
|
|
// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。
|
|
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
|
|
var out []LedgerEntry
|
|
for _, rr := range ranked {
|
|
if rr.Points == 0 {
|
|
continue
|
|
}
|
|
athleteID := int64(0)
|
|
if rr.Scope == ScopeAthlete {
|
|
athleteID = rr.AthleteID
|
|
}
|
|
sourceID := rr.ResultID
|
|
if sourceID == 0 {
|
|
sourceID = rr.EntryID
|
|
}
|
|
if sourceID == 0 {
|
|
sourceID = ch.ID
|
|
}
|
|
for _, s := range rr.Shares {
|
|
pts := rr.Points * s.Weight
|
|
if pts == 0 {
|
|
continue
|
|
}
|
|
out = append(out, LedgerEntry{
|
|
TeamID: s.TeamID,
|
|
AthleteID: athleteID,
|
|
EventID: in.Event.ID,
|
|
CategoryID: in.Category.ID,
|
|
EntryID: rr.EntryID,
|
|
ResultID: rr.ResultID,
|
|
ChannelID: ch.ID,
|
|
SourceType: SourceResult,
|
|
SourceID: sourceID,
|
|
Points: pts,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// resolvedShares 解析报名项的归属:默认整体归主单位,联队模式才按份额拆分。
|
|
func (e Entry) resolvedShares(mode UnionAttribution) []TeamShare {
|
|
shares := e.teamShares()
|
|
if len(shares) == 0 {
|
|
return nil
|
|
}
|
|
if mode != UnionSplit {
|
|
return []TeamShare{{TeamID: e.primaryTeamID(), Weight: 1}}
|
|
}
|
|
return normalizeShares(shares)
|
|
}
|
|
|
|
// normalizeShares 把份额归一化到总和 1,避免配置里权重不标准导致总分被放大。
|
|
func normalizeShares(shares []TeamShare) []TeamShare {
|
|
var sum float64
|
|
for _, s := range shares {
|
|
sum += s.Weight
|
|
}
|
|
out := make([]TeamShare, len(shares))
|
|
if sum <= 0 {
|
|
w := 1 / float64(len(shares))
|
|
for i, s := range shares {
|
|
out[i] = TeamShare{TeamID: s.TeamID, Weight: w}
|
|
}
|
|
return out
|
|
}
|
|
for i, s := range shares {
|
|
out[i] = TeamShare{TeamID: s.TeamID, Weight: s.Weight / sum}
|
|
}
|
|
return out
|
|
}
|