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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-16 23:55:12 +08:00
1 parent a85115c96e
commit 48208f395b
30 files changed
+1394 -85

No files matched your search

+44 -16
View File
@@ -298,31 +298,40 @@ function sortManual(cands: Candidate[]): void {
}
/**
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名
* 聚合通道:先把同一主体的成绩汇总(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 sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum'
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 }>()
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 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。
// valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。
if (value === null && avgMode !== 'all') continue
bucket = { row, total: 0, valid: 0, registered: 0 }
bucket = { row, total: 0, valid: 0, registered: 0, worst: null }
buckets.set(subject, bucket)
order.push(subject)
}
@@ -330,25 +339,44 @@ function aggregateCandidates(
if (value !== null) {
bucket.total += value
bucket.valid += 1
if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value
}
}
return order.map((subject) => {
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 = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0
return {
row: bucket.row,
value,
rankIn: 0,
subject,
teamId: bucket.row.teamId,
}
})
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(
@@ -368,7 +396,7 @@ function scoreChannel(
let groups: TieGroup[]
if (basis === 'aggregate') {
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare)
sortByValue(aggregated, event.compare)
groups = groupByValue(aggregated)
} else if (basis === 'manual') {
@@ -33,14 +33,32 @@ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
]
/**
* 聚合口径的两个选项:现场在「按成员平均」与「按成员总和」之间快速切。
* 聚合口径的三个选项:现场在「按成员平均」「按成员总和」「按队内最低分」之间快速切。
* 与 basis=aggregate 配套使用,点一下即选中(不做二次确认,现场要的是快)。
*
* 「队内最低分」= 队内最差的那一份成绩(不是名字里的「最低」那么简单):升序项目(时间,越小越好)
* 取的是**跑得最慢的那个人**(最后一名冲线),降序项目(次数,越大越好)取的是**做得最少的那个**,
* 方向跟着小项的「比较方向」自动走 —— 所以提示里必须把两种方向都写出来,只写「最低」会让人按字面理解。
*/
const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: string }> = [
{ value: 'avg', label: '平均', hint: '团体成绩 = 该单位成员成绩的平均值(缺省)' },
{ value: 'sum', label: '总和', hint: '团体成绩 = 该单位成员成绩的总和' },
{
value: 'worst',
label: '队内最低分',
hint:
'团体成绩 = 该单位成员里最差的那一份:升序项目(时间)取最后一名冲线的那个人,' +
'降序项目(次数)取做得最少的那个(方向跟着小项的「比较方向」走)',
},
]
/** 行内小结:一句话说清这一行的团体成绩是怎么来的,现场核对时照这一行读。 */
const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string> = {
avg: '团体成绩 = 该单位成员成绩的平均值',
sum: '团体成绩 = 该单位成员成绩的总和',
worst: '团体成绩 = 该单位成员里最差的那一份(时间项目取最后一名冲线的人 / 次数项目取做得最少的那个)',
}
interface RowError {
scope?: string
basis?: string
@@ -231,11 +249,14 @@ async function save(): Promise<void> {
</div>
<div class="bd-note count-to-team-note">
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和</b>直接切换——
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和;切换后点「保存全部」生效,
该小项立即重算。两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和 / 队内最低分</b>直接切换——
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和,
「<b>队内最低分</b>」= 该单位成员里<b>最差的那一份</b>成绩(<b>时间项目取最后一名冲线的那个人,次数项目取做得最少的那个</b>,
方向跟着小项的「比较方向」自动走——越野 / 行军这类项目要的就是它);切换后点「保存全部」生效,
该小项立即重算。平均与总和两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
平均值口径(只算有效成绩 / 全员计入)仍按小项的「平均值口径」设置,求和时它只决定
「一个人都没成绩的单位算不算 0 分参与排名」。
「一个人都没成绩的单位算不算 0 分参与排名」,「队内最低分」下它不起作用:
一个人都没成绩的单位一律不参与排名(没人冲线就没有「最后一名」可言)。
<br />
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
新增行的默认值按小项类型来:「个人+团体」的个人通道默认「不计入」(与规则包模板一致),
@@ -271,15 +292,15 @@ async function save(): Promise<void> {
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
</select>
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
{{ AGGREGATE_MODE_SUMMARIES[row.aggregateMode] }}
</span>
<span v-if="errors[row.key]?.basis" class="row-error">{{ errors[row.key]?.basis }}</span>
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
</td>
<td>
<!--
现场要在「按成员平均」与「按成员总和」之间临时定口径,所以不放进下拉:
两个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
现场要在「按成员平均」「按成员总和」「按队内最低分」之间临时定口径,所以不放进下拉:
三个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
-->
<div v-if="row.basis === 'aggregate'" class="agg-mode" role="group" aria-label="聚合口径">
<button
@@ -403,8 +424,8 @@ async function save(): Promise<void> {
margin-bottom: 10px;
}
/*
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」还是「总和」,
* 点目标按钮即选中(高亮),配合「保存全部」生效。
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」「总和」还是「队内最低分」,
* 点目标按钮即选中(高亮),配合「保存全部」生效。三个按钮横排,内边距收窄一点免得把表格撑开。
*/
.agg-mode {
display: inline-flex;
@@ -413,7 +434,7 @@ async function save(): Promise<void> {
overflow: hidden;
}
.agg-mode-btn {
padding: 4px 10px;
padding: 4px 8px;
font-size: 12px;
line-height: 1.4;
color: var(--mx-t2);
@@ -9,6 +9,7 @@ import {
normalizeCountToTeamMedals,
} from '@/shared'
import type {
AggregateMode,
AvgMode,
CompareMode,
Event,
@@ -114,6 +115,16 @@ const awardSummary = computed(() => {
return `前 ${Math.max(...ranks)} 名`
})
/**
* 小项列表里聚合通道的「名次来源」称谓:与「计分通道」页的聚合口径按钮同一套说法,
* 免得同一条通道在两张表里叫两个名字。worst 写「按队内最低分」,而不是背后的英文取值。
*/
const AGGREGATE_BASIS_LABELS: Record<AggregateMode, string> = {
avg: '按队内平均',
sum: '按队内总和',
worst: '按队内最低分',
}
const channelSummary = computed(() =>
selectedChannels.value.map((channel) => {
const profile = data.profileById(channel.profileId)
@@ -125,7 +136,7 @@ const channelSummary = computed(() =>
? '按成绩排名'
: channel.basis === 'manual'
? '手动名次'
: `按队内${normalizeAggregateMode(channel.aggregateMode) === 'sum' ? '总和' : '平均'}`,
: AGGREGATE_BASIS_LABELS[normalizeAggregateMode(channel.aggregateMode)],
profile: profile?.name ?? `模板 #${channel.profileId}`,
award: profile?.awardRanks ?? null,
countToTeam: normalizeCountToTeam(channel.countToTeam),
@@ -22,11 +22,16 @@ test('聚合口径缺字段 / 认不出的取值一律按 avg(老数据的口
assert.equal(DEFAULT_AGGREGATE_MODE, 'avg')
assert.equal(normalizeAggregateMode('sum'), 'sum')
assert.equal(normalizeAggregateMode('avg'), 'avg')
assert.equal(normalizeAggregateMode('worst'), 'worst')
// 老服务端不下发该字段(缺省值不下发),旧缓存里的脏值同样按缺省处理。
assert.equal(normalizeAggregateMode(undefined), 'avg')
assert.equal(normalizeAggregateMode(null), 'avg')
assert.equal(normalizeAggregateMode('SUM'), 'avg')
assert.equal(normalizeAggregateMode('bogus'), 'avg')
// 「取最小 / 取最大」不是取值:方向由小项的 compare 决定,写成 min / max 必须落回缺省,
// 而不是被当成某个方向(那会让一半的项目算反,且页面上看不出来)。
assert.equal(normalizeAggregateMode('min'), 'avg')
assert.equal(normalizeAggregateMode('max'), 'avg')
})
test('新增通道行默认按成员平均(avg),要用总和时现场切', () => {
@@ -42,9 +47,12 @@ test('通道编辑器读写聚合口径:存库值回填、保存时随行回
})
test('聚合口径是「一点即切」的显眼控件,且只在聚合通道上出现', () => {
// 两个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
// 三个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
assert.match(CHANNELS_SOURCE, /\{ value: 'avg', label: '平均', hint: /)
assert.match(CHANNELS_SOURCE, /\{ value: 'sum', label: '总和', hint: /)
// 第三个口径的选项写成多行(提示文案长),两段分开断言,别把格式绑死在一条正则上。
assert.match(CHANNELS_SOURCE, /value: 'worst'/)
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
assert.match(CHANNELS_SOURCE, /class="agg-mode-btn"/)
assert.match(CHANNELS_SOURCE, /:class="\{ 'is-active': row\.aggregateMode === option\.value \}"/)
assert.match(CHANNELS_SOURCE, /@click="row\.aggregateMode = option\.value"/)
@@ -53,3 +61,18 @@ test('聚合口径是「一点即切」的显眼控件,且只在聚合通道
// 表头要让人找到它。
assert.match(CHANNELS_SOURCE, /<th class="bd-select">聚合口径<\/th>/)
})
test('新增的「队内最低分」在界面上说清方向,不把英文取值甩给操作员', () => {
// 按钮文案是中文口径名,不是 worst;提示里必须两种方向都写出来
// (升序 = 最后一名冲线、降序 = 做得最少的那个),否则操作员会按字面理解成「取最小的那个」。
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
assert.match(CHANNELS_SOURCE, /最后一名冲线/)
assert.match(CHANNELS_SOURCE, /做得最少的那个/)
assert.doesNotMatch(CHANNELS_SOURCE, /label: 'worst'/)
// 行内小结按口径给整句话,三个口径都要有(切到 worst 时不能显示平均那句)。
assert.match(CHANNELS_SOURCE, /const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string>/)
assert.match(CHANNELS_SOURCE, /worst: '团体成绩 = 该单位成员里最差的那一份/)
assert.match(CHANNELS_SOURCE, /\{\{ AGGREGATE_MODE_SUMMARIES\[row\.aggregateMode\] \}\}/)
// 表格上方那段说明也要提到第三个口径,否则现场只看到按钮不知道它是干什么的。
assert.match(CHANNELS_SOURCE, /平均 \/ 总和 \/ 队内最低分/)
})
@@ -0,0 +1,180 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
// 聚合口径的第三个取值 worst(按队内最差 / 最后一名冲线)在录入页预览引擎里的口径。
//
// 预览与服务端 domain/scoring.go 的 scoreByAggregate 必须**同一口径**:下面每一组用例的
// 输入与期望值都与 server/internal/domain/aggregate_worst_test.go 里同款用例逐条对应
// (同一组成绩、同一份积分表 15/10/8),两边算出来的**名次与分**要对得上。
//
// 最容易搞反的一点:worst 取的到底是最大还是最小,**取决于小项的 compare 方向**——
// asc(时间,越小越好)取最大(最后一名冲线);desc(次数,越大越好)取最小(做得最少的那个)。
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'TEAM_PULLUP',
name: '团体引体向上',
eventType: 'team',
resultMode: 'auto',
resultType: 'count',
compare: 'desc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(): ScoringProfile {
return {
id: 2,
code: 'team_top6',
name: '团体项目前六名',
tieMode: 'skip',
recordBonusMode: 'max',
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
],
}
}
function makeChannel(aggregateMode: 'avg' | 'sum' | 'worst' | undefined): ScoreChannel {
return {
id: 3,
eventId: 1,
scope: 'team',
basis: 'aggregate',
profileId: 2,
sortOrder: 1,
aggregateMode,
countToTeam: true,
}
}
function makeRow(entryId: number, resultValue: number | null, teamId: number): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank: null, status: 'ok', isTie: false }
}
/** 某个单位在这次计分里拿到的团体分(单位总分只来自这条聚合通道),按单位号排序后返回。 */
function teamPoints(
rows: ScorableRow[],
aggregateMode: 'avg' | 'sum' | 'worst' | undefined,
avgMode: Event['avgMode'] = 'valid',
compare: Event['compare'] = 'desc',
): Array<[number, number]> {
const context: ScoreContext = {
event: makeEvent({ avgMode, compare }),
category: null,
channels: [makeChannel(aggregateMode)],
profiles: [makeProfile()],
weight: 1,
}
const points = computeEventScore(rows, context).teamPoints
return [...points.entries()].sort((a, b) => a[0] - b[0])
}
test('worst:asc(时间)取队内**最慢**的那份 —— 最后一名冲线的队伍排前面', () => {
// 100 队 12/15/20、101 队 13/14、102 队 30(分钟):队内最慢 20 / 14 / 30。
const rows = [
makeRow(1, 12, 100),
makeRow(2, 15, 100),
makeRow(3, 20, 100),
makeRow(4, 13, 101),
makeRow(5, 14, 101),
makeRow(6, 30, 102),
]
// 越小越好:101(14)第 1、100(20)第 2、102(30)第 3 —— 与 domain 包同名用例逐条一致。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 10],
[101, 15],
[102, 8],
])
})
test('worst:asc 下取最慢 —— 名次与 avg 不同(防止把 worst 当成 avg 的别名)', () => {
// 100 队 10/30、101 队 20/21:队内最慢 30 / 21(101 更慢但更稳);平均 20 / 20.5。
const rows = [makeRow(1, 10, 100), makeRow(2, 30, 100), makeRow(3, 20, 101), makeRow(4, 21, 101)]
// 按队内最慢:101(21)第 1、100(30)第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 10],
[101, 15],
])
// 按平均:100(20)第 1、101(20.5)第 2 —— 正好互换。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'asc'), [
[100, 15],
[101, 10],
])
})
test('worst:desc(次数)取队内**最少**的那份 —— 与 avg 的名次正好互换', () => {
// 100 队 20 + 1 个(平均 10.5、队内最少 1),101 队 5 + 6 个(平均 5.5、队内最少 5)。
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
// 越大越好:按队内最少算,101(5)第 1、100(1)第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
[100, 10],
[101, 15],
])
// 平均口径下 100(10.5)第 1 —— 与 worst 相反,说明方向真的按 compare 取了。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
[100, 15],
[101, 10],
])
})
test('worst:队内最差相同的两队并列,并列后顺延(与既有并列口径一致)', () => {
const rows = [makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 20, 101), makeRow(4, 25, 102)]
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
[100, 15],
[101, 15],
[102, 8],
])
})
test('worst:一个有效成绩都没有的单位不参与排名(与 avg 的 valid 口径同一条规则)', () => {
const rows = [makeRow(1, 12, 100), makeRow(2, 20, 100), makeRow(3, null, 101), makeRow(4, null, 101)]
// asc 下 101 若按 0 参与会白拿第 1 名(0 是最快的),所以 valid 与 all 都不参与。
for (const avgMode of ['valid', 'all'] as const) {
assert.deepEqual(teamPoints(rows, 'worst', avgMode, 'asc'), [[100, 15]])
}
// 对照:avg 的 all 口径下零成绩单位按 0 参与(既有口径,不是 worst 的)。
assert.deepEqual(teamPoints(rows, 'avg', 'all', 'asc'), [
[100, 10],
[101, 15],
])
})
test('worst:弃权 / 失格的人的成绩不算数(队内最差只在实际出赛的人里取)', () => {
// 100 队:5、6 个,另有一人弃赛(那一格写了 0)。101 队:4、9 个。
const scratched: ScorableRow = { ...makeRow(3, 0, 100), status: 'withdrawn' }
const rows = [makeRow(1, 5, 100), makeRow(2, 6, 100), scratched, makeRow(4, 4, 101), makeRow(5, 9, 101)]
// 越大越好:100 队队内最少 5 > 101 队队内最少 4 —— 100 第 1、101 第 2。
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
[100, 15],
[101, 10],
])
// 两处判别力:① 把弃赛那条的 0 算进来,100 队就垫底(0 是最少的);② 按平均算,101(6.5)反而第一。
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
[100, 10],
[101, 15],
])
})
test('worst:缺省仍是 avg(老数据 / 老服务端不下发该字段时口径一字不变)', () => {
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
assert.deepEqual(teamPoints(rows, undefined), teamPoints(rows, 'avg'))
// 与 worst 明显不同,证明确实落在 avg 一侧,而不是「怎么算都行」。
assert.notDeepEqual(teamPoints(rows, undefined, 'valid', 'desc'), teamPoints(rows, 'worst', 'valid', 'desc'))
})
+1 -1
View File
@@ -9,7 +9,7 @@ import type { AggregateMode, AvgMode, Event, EventInput, ScoreChannel } from './
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum']
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum', 'worst']
/** 聚合通道默认按成员平均汇总;缺字段的老数据 / 老服务端都按它处理。 */
export const DEFAULT_AGGREGATE_MODE: AggregateMode = 'avg'
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
+2 -2
View File
@@ -229,8 +229,8 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
// 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。
if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals
if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals
// 汇总口径同样是三态:只在规则包显式写了 avg / sum 时落到对象上(缺省 = avg,不写)。
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum') {
// 汇总口径同样是三态:只在规则包显式写了 avg / sum / worst 时落到对象上(缺省 = avg,不写)。
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum' || rec.aggregateMode === 'worst') {
channel.aggregateMode = normalizeAggregateMode(rec.aggregateMode)
}
out.push(channel)
+27
View File
@@ -39,3 +39,30 @@ test('规则包模板仍接受原有三种类型', () => {
assert.equal(pack.channelTemplates[0]?.match.eventType, eventType)
}
})
// 规则包里显式写的汇总口径要落到通道对象上(缺省 = 不写 = avg,导出页才能如实显示原文口径);
// 认不出的取值不落到对象上,也不会把 avg / sum / worst 之外的东西带进写请求。
test('规则包通道保留 aggregateMode:avg / sum / worst 都认,缺省不写', () => {
const build = (aggregateMode: unknown) =>
normalizeRulePack({
format: 'scoreboard-rules',
formatVersion: 1,
meta: { code: 'demo', name: '示例规则' },
profiles: [{ code: 'p', name: '模板', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 1 }] }],
channelTemplates: [
{ match: { eventType: 'team' }, channels: [{ scope: 'team', basis: 'aggregate', profile: 'p', aggregateMode }] },
],
})
for (const mode of ['avg', 'sum', 'worst'] as const) {
const pack = build(mode)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, mode)
}
// 缺省(不写)与认不出的取值都不落到对象上:既不凭空写缺省值,也不把 min / max 当取值带过去。
for (const missing of [undefined, '', 'min', 'max', 'bogus']) {
const pack = build(missing)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, undefined)
}
})
+11 -4
View File
@@ -85,10 +85,17 @@ export type AvgMode = 'valid' | 'all'
/**
* 聚合通道(basis=aggregate)的汇总口径:
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名。
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」。
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名;
* `worst` 按成员成绩里**最差的一份**排名(队内最差 / 最后一名冲线)。
*
* `worst` 刻意不叫 `min` / `max`:取最小还是取最大**由小项的 compare 决定**
* (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个),
* 写死成任何一个方向都会让一半的项目算反(见 admin/api/scoring.ts 的 worseThan)。
*
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」;
* `worst` 没有分母,一个有效成绩都没有的单位一律不参与排名(与 avg 的 valid 同规则)。
*/
export type AggregateMode = 'avg' | 'sum'
export type AggregateMode = 'avg' | 'sum' | 'worst'
export interface ScoreChannel {
id: number
@@ -98,7 +105,7 @@ export interface ScoreChannel {
profileId: number
sortOrder: number
/**
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和。
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和 / `worst` 按队内最差。
* 缺字段 = avg:老服务端不下发它(缺省值不下发),归一化见 normalizeAggregateMode。
*/
aggregateMode?: AggregateMode