Files
Scoreboard/web/src/admin/api/scoring.ts
T
AskaEth 48208f395b feat(scoring): 新增「队内最差(最后一名冲线)」聚合口径;版本 dev_0.5.11
## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。

## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:

| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |

方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。

## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
  取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
  (父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
  提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
  这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。

## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
  否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
  若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。

## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。

## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-16 23:55:12 +08:00

647 lines
26 KiB
TypeScript
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.
import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam, splitSharePoints } from '@/shared'
import type {
AvgMode,
ChannelBasis,
ChannelScope,
CompareMode,
Event,
EventCategory,
ResultStatus,
ScoreChannel,
ScoringProfile,
TeamRankEntry,
TieMode,
} from '@/shared'
/**
* 名次与积分的纯函数实现,只用于录入页的实时预览与 mock 数据源。
*
* 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
* 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
* 计入单位总分的通道再按 standings.individual_to_team_weight 折算进单位总分。
* 「按单位录名次」的通道(scope=team + basis=manual)另有一条候选来源,
* 见 collectTeamManualCandidates——它读的是 channel_manual_ranks,不是行上的名次。
* 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
*/
/** 通道「名次范围」的界面称谓;管理端下拉与预览报错文案共用这一份,免得同一件事两处措辞不同。 */
export const CHANNEL_SCOPE_LABELS: Record<ChannelScope, string> = {
athlete: '个人名次',
team: '团体名次',
}
/** 通道「名次来源」的界面称谓。 */
export const CHANNEL_BASIS_LABELS: Record<ChannelBasis, string> = {
result: '按成绩自动排名',
manual: '手动录名次',
aggregate: '按队内平均',
}
/** 通道在界面上的全称,如「团体名次 / 手动录名次」。 */
export function channelLabelOf(scope: ChannelScope, basis: ChannelBasis): string {
return `${CHANNEL_SCOPE_LABELS[scope]} / ${CHANNEL_BASIS_LABELS[basis]}`
}
export interface ScorableRow {
entryId: number
teamId: number
/** 聚合到运动员的通道需要;缺失时该行不参与个人维度的聚合。 */
athleteId?: number | null
resultValue: number | null
rank: number | null
status: ResultStatus
isTie: boolean
/**
* 归属份额(联队,devdocs/19);普通报名不传 = 主单位独占 100%。
*
* 引擎侧按份额把分拆给各成员单位(scoring.go 的 ledgerFromRanked),录入页的实时预览要同口径,
* 否则联队那条会显示成「A +15」,而发布后 A、B 各 +7.5。
*/
shares?: readonly { teamId: number; weight: number }[]
}
const EPSILON = 1e-9
/** 不计分项目的占位模板:名次要算,但不可能算出非零分。 */
const EMPTY_PROFILE: ScoringProfile = {
id: 0,
code: '',
name: '',
tieMode: 'skip',
recordBonusMode: 'max',
rules: [],
}
interface Candidate {
row: ScorableRow
/** 比较值(result / aggregate 通道)。 */
value: number
/** 录入名次(manual 通道)。 */
rankIn: number
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
subject: number
/** 该候选计入单位总分时的归属单位。 */
teamId: number
}
interface RankedRow {
/** 聚合通道不对应单条报名项,此时为 0。 */
entryId: number
teamId: number
scope: ChannelScope
rank: number
points: number
}
function effectiveMultiplier(value: number): number {
return value > 0 ? value : 1
}
function tieModeOf(profile: ScoringProfile): TieMode {
return profile.tieMode === 'average' || profile.tieMode === 'higher' ? profile.tieMode : 'skip'
}
/** 查表得分并乘项目 / 组别权重;超出录取名次或表中无名次一律 0(scoring.go basePoints)。 */
function basePoints(
profile: ScoringProfile,
rank: number,
eventMultiplier: number,
categoryMultiplier: number,
): number {
if (rank <= 0) return 0
if (profile.awardRanks !== undefined && profile.awardRanks > 0 && rank > profile.awardRanks) return 0
const rule = profile.rules.find((item) => item.rank === rank)
if (rule === undefined) return 0
return rule.points * eventMultiplier * categoryMultiplier
}
/**
* 一组并列者拿到的分(scoring.go groupPoints)。
* 先确定这组占用的名次区间 startRank .. startRank+size-1,再按并列口径取值。
*/
function groupPoints(
profile: ScoringProfile,
startRank: number,
size: number,
lastRank: number,
eventMultiplier: number,
categoryMultiplier: number,
): number {
if (startRank <= 0 || size <= 0) return 0
// lastPlaceZero 只抹「末位那一档」,判据是起始名次**正好落在这档**(scoring.go groupPoints)。
// 这种组必然只有一个人(startRank == lastRank 意味着 size == 1,lastRank 是所有组占位名次的
// 最大值)。判据若写成「区间跨过末位」,两人并列第 1(占 1、2,末位档 = 2)会被整组归零,
// 而服务端照发第 1 名的分——这就是现场「预览 0、服务端 10」的来历。
if (profile.lastPlaceZero === true && startRank === lastRank) return 0
const mode = tieModeOf(profile)
if (mode === 'average') {
let sum = 0
for (let rank = startRank; rank < startRank + size; rank += 1) {
sum += basePoints(profile, rank, eventMultiplier, categoryMultiplier)
}
return sum / size
}
if (mode === 'higher') {
let best = 0
for (let rank = startRank; rank < startRank + size; rank += 1) {
best = Math.max(best, basePoints(profile, rank, eventMultiplier, categoryMultiplier))
}
return best
}
return basePoints(profile, startRank, eventMultiplier, categoryMultiplier)
}
/** 收集本通道真正参与排名的行:状态正常,且带有本通道所需数据。 */
function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): Candidate[] {
const out: Candidate[] = []
for (const row of rows) {
if (row.status !== 'ok') continue
if (basis === 'manual') {
if (row.rank === null || row.rank <= 0) continue
out.push({ row, value: 0, rankIn: row.rank, subject: 0, teamId: row.teamId })
continue
}
if (row.resultValue === null) continue
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0, teamId: row.teamId })
}
return out
}
/** 一行的归属份额是否构成联队(与 Go 的 jointTeamEntry 同一判据:至少两个单位)。 */
function isJointShares(row: ScorableRow): boolean {
return row.shares !== undefined && row.shares.length >= 2
}
/**
* 找「主单位 teamID 在本组别的联队报名」(scoring.go jointTeamEntry)。
*
* 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上,
* 所以录名次时得回头找这条报名,否则 A+B 的名次会整份记在 A 头上、B 一分没有。
* 只认团体行(athleteId 为 0):个人报名的名次是个人名次,不进团体通道。
*/
function jointTeamRow(rows: readonly ScorableRow[], teamId: number): ScorableRow | null {
let found: ScorableRow | null = null
for (const row of rows) {
if ((row.athleteId ?? 0) !== 0 || row.teamId !== teamId || !isJointShares(row)) continue
// 同一主单位出现份额不一致的两条时宁可退回独占,也不能随便挑一条把分拆错。
if (found !== null && !sameShares(found, row)) return null
found = row
}
return found
}
function sameShares(a: ScorableRow, b: ScorableRow): boolean {
const left = a.shares ?? []
const right = b.shares ?? []
if (left.length !== right.length) return false
return left.every((share, index) => share.teamId === right[index]?.teamId && share.weight === right[index]?.weight)
}
/**
* 「按单位录名次」通道(scope=team + basis=manual)的候选来源(scoring.go collectTeamManualCandidates)。
*
* 两个来源,且**只看团体行**(athleteId 为 0):
* 1. teamRanks(channel_manual_ranks,录入页底部的「团体名次」卡片):没有对应的报名项,
* 用合成行(entryId 0)承载,分直接记在单位头上;
* 2. 兜底:团体行上录的名次(results.rank),纯团体项目(篮球等)沿用原录入流程。
*
* 同一单位两个来源都有时以 teamRanks 为准;rank <= 0 视为未填 / 清空,直接忽略。
* 个人报名行(athleteId != 0)的名次绝不进团体通道——否则双通道项目第 2 次发布时会把
* 刚写回 results.rank 的个人名次当成团体名次再发一次分。
* 录入页的「团体名次」卡片此前完全没喂给预览,于是「名次填在卡片、预览全 0」成了现场的 P0。
*/
function collectTeamManualCandidates(
rows: readonly ScorableRow[],
teamRanks: readonly TeamRankEntry[],
): Candidate[] {
const overrideOf = new Map<number, number>()
const overrides: number[] = []
for (const entry of teamRanks) {
if (entry.teamId === 0 || entry.rank <= 0) continue
if (overrideOf.has(entry.teamId)) continue
overrideOf.set(entry.teamId, entry.rank)
overrides.push(entry.teamId)
}
const out: Candidate[] = []
const taken = new Set<number>()
for (const row of rows) {
if ((row.athleteId ?? 0) !== 0 || row.status !== 'ok') continue
if (row.rank === null || row.rank <= 0) continue
const rankIn = overrideOf.get(row.teamId) ?? row.rank
taken.add(row.teamId)
out.push({ row, value: 0, rankIn, subject: 0, teamId: row.teamId })
}
for (const teamId of overrides) {
if (taken.has(teamId)) continue
const rankIn = overrideOf.get(teamId) ?? 0
const joint = jointTeamRow(rows, teamId)
if (joint !== null) {
out.push({ row: joint, value: 0, rankIn, subject: 0, teamId })
continue
}
// 合成行:entryId 0 表示「不对应任何一条报名项」,团体名次的主体是单位。
out.push({
row: { entryId: 0, teamId, athleteId: 0, resultValue: null, rank: rankIn, status: 'ok', isTie: false },
value: 0,
rankIn,
subject: 0,
teamId,
})
}
return out
}
function sortByValue(cands: Candidate[], compare: CompareMode): void {
cands.sort((a, b) => {
if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId
return compare === 'desc' ? b.value - a.value : a.value - b.value
})
}
interface TieGroup {
startRank: number
members: Candidate[]
}
/** 值相同即并列,名次按「占位顺推」计算(skip 口径)。 */
function groupByValue(cands: readonly Candidate[]): TieGroup[] {
const groups: TieGroup[] = []
let pos = 1
for (let i = 0; i < cands.length; ) {
let j = i + 1
while (j < cands.length && Math.abs((cands[j] as Candidate).value - (cands[i] as Candidate).value) <= EPSILON) {
j += 1
}
groups.push({ startRank: pos, members: cands.slice(i, j) })
pos += j - i
i = j
}
return groups
}
/** 录入名次就是展示名次,不做顺推改写。 */
function groupByManualRank(cands: readonly Candidate[]): TieGroup[] {
const groups: TieGroup[] = []
for (let i = 0; i < cands.length; ) {
let j = i + 1
while (j < cands.length && (cands[j] as Candidate).rankIn === (cands[i] as Candidate).rankIn) j += 1
groups.push({ startRank: (cands[i] as Candidate).rankIn, members: cands.slice(i, j) })
i = j
}
return groups
}
function sortManual(cands: Candidate[]): void {
cands.sort((a, b) => (a.rankIn === b.rankIn ? a.row.entryId - b.row.entryId : a.rankIn - b.rankIn))
}
/**
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和 / worst 取队内最差),再按汇总值排名
* (scoring.go scoreByAggregate)。
*
* 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定:
* valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。
* sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。
* worst 没有分母,avgMode 在这里**不起作用**:一个有效成绩都没有的主体一律不参与排名
* (与 avg 的 valid 同一条规则)——不能把「没成绩」当成 0 去比:asc 里 0 最快、desc 里 0 最差,
* 两边都会让「没人完赛」的队白拿名次。
* scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
*/
function aggregateCandidates(
rows: readonly ScorableRow[],
channel: ScoreChannel,
avgMode: AvgMode,
compare: CompareMode,
): Candidate[] {
const mode = normalizeAggregateMode(channel.aggregateMode)
const sumMode = mode === 'sum'
const worstMode = mode === 'worst'
const order: number[] = []
const buckets = new Map<
number,
{ row: ScorableRow; total: number; valid: number; registered: number; worst: number | null }
>()
for (const row of rows) {
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
if (subject === 0) continue
const value = row.status === 'ok' ? row.resultValue : null
let bucket = buckets.get(subject)
if (bucket === undefined) {
// valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。
if (value === null && avgMode !== 'all') continue
bucket = { row, total: 0, valid: 0, registered: 0, worst: null }
buckets.set(subject, bucket)
order.push(subject)
}
bucket.registered += 1
if (value !== null) {
bucket.total += value
bucket.valid += 1
if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value
}
}
const out: Candidate[] = []
for (const subject of order) {
const bucket = buckets.get(subject) as {
row: ScorableRow
total: number
valid: number
registered: number
worst: number | null
}
if (worstMode && bucket.worst === null) continue
const count = avgMode === 'all' ? bucket.registered : bucket.valid
const value = worstMode
? (bucket.worst as number)
: sumMode
? bucket.total
: count > 0
? bucket.total / count
: 0
out.push({ row: bucket.row, value, rankIn: 0, subject, teamId: bucket.row.teamId })
}
return out
}
/**
* 判断 a 是不是比 b 更「差」——**方向由小项的 compare 决定,这是最容易写反的一处**
* (scoring.go 的 worseValue 同款):
*
* asc (越小越好,如时间):更差 = 更大 →「队内最差」是**最后一名冲线**的那个;
* desc(越大越好,如次数):更差 = 更小 →「队内最差」是**做得最少**的那个。
*
* 写死成 min 或 max 都会让一半的项目算反(越野会取到跑得最快的人、引体向上会取到做得最多的人),
* 而两种错法读起来都很正常,所以预览与后端各有 asc / desc 的双向用例钉着。
*/
function worseThan(a: number, b: number, compare: CompareMode): boolean {
return compare === 'desc' ? a < b : a > b
}
function scoreChannel(
rows: readonly ScorableRow[],
channel: ScoreChannel,
profile: ScoringProfile,
event: Event,
category: EventCategory | null,
scoreEnabled: boolean,
teamRanks: readonly TeamRankEntry[],
): RankedRow[] {
// compare=none 的降级(照旧按录入名次)已在这里去掉:服务端对这种配置直接返回
// ErrCompareRequired(发布 409),调用方先拦下并报错,见 computeEventScore。
const basis: ChannelBasis = channel.basis
const eventMultiplier = effectiveMultiplier(event.multiplier)
const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1)
let groups: TieGroup[]
if (basis === 'aggregate') {
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare)
sortByValue(aggregated, event.compare)
groups = groupByValue(aggregated)
} else if (basis === 'manual') {
// 「按单位录名次」读 channel_manual_ranks(+ 团体行兜底),个人名次才读行上的 rank。
const cands =
channel.scope === 'team' ? collectTeamManualCandidates(rows, teamRanks) : collectCandidates(rows, 'manual')
sortManual(cands)
groups = groupByManualRank(cands)
} else {
const cands = collectCandidates(rows, 'result')
sortByValue(cands, event.compare)
groups = groupByValue(cands)
}
let lastRank = 0
for (const group of groups) {
lastRank = Math.max(lastRank, group.startRank + group.members.length - 1)
}
const out: RankedRow[] = []
for (const group of groups) {
const points = scoreEnabled
? groupPoints(profile, group.startRank, group.members.length, lastRank, eventMultiplier, categoryMultiplier)
: 0
for (const member of group.members) {
out.push({
entryId: basis === 'aggregate' ? 0 : member.row.entryId,
teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.teamId,
scope: channel.scope,
rank: group.startRank,
points,
})
}
}
return out
}
/** 没有计分通道时按小项自身的录入方式推导一个排名通道(scoring.go rankOnlyChannel)。 */
function rankOnlyChannel(event: Event): ScoreChannel {
const scope: ChannelScope = event.eventType === 'team' || event.eventType === 'match' ? 'team' : 'athlete'
return {
id: 0,
eventId: event.id,
scope,
basis: event.resultMode === 'manual' ? 'manual' : 'result',
profileId: 0,
sortOrder: 0,
countToTeam: true,
}
}
/** 一行折算到某个单位的贡献(联队时一行会有多条)。 */
export interface RowTeamShare {
teamId: number
points: number
}
/**
* 预览与服务端**都算不出来**的通道配置。
*
* 目前只有一种:compare=none 的小项配了 basis=result / basis=aggregate 的通道。
* 服务端 domain.ScoreEvent 对这种配置直接返回 ErrCompareRequired,发布会 409(STATE_INVALID);
* 预览以前把它静默降级成「按录入名次」算出一份服务端根本拿不到的分,操作员照着这个数发布只会失败。
*/
export interface ScoreConfigError {
/** 出问题的通道 id(通道还没入库时可能是 0)。 */
channelId: number
/** 通道在界面上的称谓,如「团体名次 / 按成绩自动排名」。 */
channelLabel: string
/** 人话原因,页面直接显示给操作员。 */
message: string
}
export interface EventScoreResult {
/** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
rowPoints: Map<number, number>
/** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
rowTeamPoints: Map<number, number>
/**
* 上一行贡献按份额拆到各成员单位的结果,普通报名只有一条(主单位)。
*
* 「已发布的分要从单位总分里扣掉」时不能按 `row.teamId` 累加 rowTeamPoints:
* 联队那条的整份分会全记在主单位头上,扣出来的差值在 B 单位那边是错的。
*/
rowTeamShares: Map<number, RowTeamShare[]>
/** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
ranks: Map<number, number | null>
/** 按单位聚合的得分增量,用于预览面板。 */
teamPoints: Map<number, number>
/**
* 不挂在任何报名项上的单位分(entryId = 0 的名次:按单位录的团体名次、按队内平均的团体成绩)。
*
* 「已发布的分从单位总分里扣掉」时要把它一起扣:这些分已经随发布 / 保存团体名次进了榜单,
* 只扣行分会让预览把它们当成新增量报出去(现场那张预览正是发布确认框「最大分差」的来源)。
*/
unitPoints: Map<number, number>
/** 配置错误(非 null 时所有分都是空的,页面应显示 message 而不是数字)。 */
error: ScoreConfigError | null
}
/** compare=none 的小项排不出名次:basis=result / aggregate 的通道服务端一律拒绝(scoring.go 的两处 ErrCompareRequired)。 */
function compareRequiredError(event: Event, channel: ScoreChannel): ScoreConfigError | null {
if (event.compare !== 'none') return null
if (channel.basis !== 'result' && channel.basis !== 'aggregate') return null
const label = channelLabelOf(channel.scope, channel.basis)
return {
channelId: channel.id,
channelLabel: label,
message:
`通道「${label}」要按成绩排名,而小项「${event.name}」是「不比较」(compare=none):` +
'服务端算不出名次、发布会直接拒绝(STATE_INVALID)。' +
'请到「基础数据 → 计分通道」把它改成「手动录名次」,或先给小项设置比较方向(升序 / 降序)。',
}
}
/**
* 把一行的单位贡献拆到各成员单位(scoring.go 的 ledgerFromRanked 同口径)。
*
* 聚合名次(entryId = 0,主体是单位或运动员)与没有配份额的行一律「主单位独占」:
* 引擎那边聚合并联队不拆份额(名次不对应报名项,读取侧回查不到份额),预览必须同口径,
* 否则会出现「预览按份额拆、发布后整份记给主单位」。
*/
function rowTeamShares(
entryId: number,
teamId: number,
shares: readonly { teamId: number; weight: number }[] | undefined,
contribution: number,
): RowTeamShare[] {
if (entryId <= 0 || shares === undefined || shares.length < 2) {
return [{ teamId, points: contribution }]
}
const parts = splitSharePoints(contribution, shares)
return shares.map((share, index) => ({ teamId: share.teamId, points: parts[index] ?? 0 }))
}
export interface ScoreContext {
event: Event
category: EventCategory | null
channels: readonly ScoreChannel[]
profiles: readonly ScoringProfile[]
/** standings.individualToTeamWeight,个人分折算到单位的系数。 */
weight: number
/**
* channel_manual_ranks:录入页「团体名次」卡片里按单位录的名次,只有
* scope=team + basis=manual 的通道读它(服务端 EventScoreInput.TeamRanks 同口径)。
* 不传 = 该组别还没读到团体名次(预览会按「一行都没有」算,而不是按行上的名次兜底)。
*/
teamRanks?: readonly TeamRankEntry[]
}
/** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */
function sortedChannels(channels: readonly ScoreChannel[]): ScoreChannel[] {
return [...channels].sort((a, b) => (a.sortOrder === b.sortOrder ? a.id - b.id : a.sortOrder - b.sortOrder))
}
export function computeEventScore(rows: readonly ScorableRow[], context: ScoreContext): EventScoreResult {
const event = context.event
const rankOnly = context.channels.length === 0
// 只排名不计分:scored=false 或没有计分通道时都算出名次,但一律 0 分。
const scoreEnabled = event.scored && !rankOnly
const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels)
const weight = context.weight > 0 ? context.weight : 1
const teamRanks = context.teamRanks ?? []
const rowPoints = new Map<number, number>()
const rowTeamPoints = new Map<number, number>()
const rowShares = new Map<number, RowTeamShare[]>()
const ranks = new Map<number, number | null>()
const rankScope = new Map<number, ChannelScope>()
const teamPoints = new Map<number, number>()
const unitPoints = new Map<number, number>()
// 配置本身就出不来分的(compare=none + basis=result/aggregate):与服务端一样停在这里,
// 一分不算、名次也不给,页面按 error 显示原因——绝不再降级算出一份服务端拿不到的分。
for (const channel of channels) {
const error = compareRequiredError(event, channel)
if (error !== null) {
for (const row of rows) ranks.set(row.entryId, null)
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error }
}
}
const sharesByEntry = new Map<number, readonly { teamId: number; weight: number }[]>()
for (const row of rows) {
if (row.shares !== undefined && row.shares.length > 0) sharesByEntry.set(row.entryId, row.shares)
}
for (const channel of channels) {
const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
// 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
const ranked = scoreChannel(
rows,
channel,
profile ?? EMPTY_PROFILE,
event,
context.category,
scoreEnabled && profile !== null,
teamRanks,
)
// countToTeam=false(例如「个人+团体」的个人通道):分照算,但不进单位总分。
const countToTeam = normalizeCountToTeam(channel.countToTeam)
for (const row of ranked) {
// 个人通道的分要按个人→单位系数折算,团体通道的分本来就是单位的分。
const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
const parts = countToTeam
? rowTeamShares(row.entryId, row.teamId, sharesByEntry.get(row.entryId), teamContribution)
: []
if (row.entryId > 0) {
rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
if (countToTeam) {
rowTeamPoints.set(
row.entryId,
(rowTeamPoints.get(row.entryId) ?? 0) + teamContribution,
)
// 多通道叠加时按单位合并,而不是按下标对齐:渠道顺序一变,按下标就会串位。
const merged = new Map((rowShares.get(row.entryId) ?? []).map((part) => [part.teamId, part.points]))
for (const part of parts) {
merged.set(part.teamId, (merged.get(part.teamId) ?? 0) + part.points)
}
rowShares.set(
row.entryId,
[...merged].map(([teamId, points]) => ({ teamId, points })),
)
}
// 名次优先取个人通道;同一优先级下先处理的通道胜出。
const current = rankScope.get(row.entryId)
if (current === undefined || (current === 'team' && row.scope === 'athlete')) {
ranks.set(row.entryId, row.rank)
rankScope.set(row.entryId, row.scope)
}
}
if (!countToTeam) continue
for (const part of parts) {
teamPoints.set(part.teamId, (teamPoints.get(part.teamId) ?? 0) + part.points)
// 不挂在报名项上的那一份单独留一份账:它已经随发布 / 保存团体名次进过榜单,
// 预览要扣已发布的分时得能把它扣掉(见 EventScoreResult.unitPoints)。
if (row.entryId <= 0) unitPoints.set(part.teamId, (unitPoints.get(part.teamId) ?? 0) + part.points)
}
}
}
for (const row of rows) {
if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
}
return { rowPoints, rowTeamPoints, rowTeamShares: rowShares, ranks, teamPoints, unitPoints, error: null }
}