Files
OJ2/packages/contract/src/problem.ts
T
xuyueandClaude Opus 5.5 55bdf317f1
Deploy / deploy (push) Successful in 55s
fix(语法要求): 「自定义函数」不再把 main 算进去,运算符不再把取地址、指针、模板尖括号当成用了
- C / C++ 的函数定义排掉 main,下拉改叫「自定义函数」。原来「必须使用」永远通过、「不能使用」永远失败,
  出题页挑战候选里 C 的「用自定义函数再写一遍」也因此一直挖不出来
- 运算符只认二元运算 / 复合赋值 / 自增自减里的 token,一元只认取反。scanf 的 &、int *p、vector<int>、
  *args、import *、**kw、not in、is not、case 1 | 2 不再算「用了」
- Python 的 math.sqrt(4) 算调用了 sqrt()(只认 import 进来的模块)
- Python 的「赋值语句」连 s += 1 一起算,和 C 一个口径
- 保存时拦下负数次数、至少比至多还多的次数规则

现有 19 道题的规则都不受影响:1265 条真实提交重判,结论和改之前一致。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:29:57 +08:00

625 lines
28 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 { z } from 'zod'
import { achievementRaritySchema } from './achievement'
import { paginatedSchema, sampleUserSchema } from './common'
import { judgeStatusSchema } from './judge-status'
import { problemLanguageSchema } from './language'
/**
* 题目难度。生产库 956 道题只有这三个值(旧 Django 的 Problem.difficulty choices
* 也是这三个),前端的 DIFFICULTY 映射表按它建 —— 写成 z.string() 的话
* 多出来的值会静默渲染成 undefined。
*/
export const problemDifficultySchema = z.enum(['Low', 'Mid', 'High'])
/**
* 题目详情/列表里的难度。**可为 null** —— 比赛进行中、看的人又不是管理员时,
* 难度和提交数一样属于「先别告诉参赛者」的信息(旧 Django 的
* `ProblemSafeSerializer` 直接把 difficulty 放进 exclude,字段整个不下发)。
*
* 端上必须当「没有」处理,不要拿它去查 DIFFICULTY 映射表 —— 这正是
* problemDifficultySchema 写成严格枚举要防的那件事。
*/
export const maskedProblemDifficultySchema = problemDifficultySchema.nullable()
/**
* SQL 题配置与展示数据。两者都是 `problem.sql_config` / `problem.sql_display`
* 的 **JSONB 原文**,所以键名保持 snake_case —— 生产库 9 道 SQL 题存的就是这个形状
* (移植自旧后端 `judge/sql_runner.py:build_display`,逐条比对过,键集完全一致)。
*
* 写成精确 schema 而不是 `z.record(z.unknown())`:前端原来得自己手抄一份
* SQLDisplay 接口才能渲染表格,抄错了没人拦得住。
*/
export const sqlConfigSchema = z.object({
mode: z.enum(['query', 'modify']),
order_sensitive: z.boolean(),
})
/** 表格里的单元格。SQLite 只会给出这三种;BLOB 在落库前已转成十六进制字符串 */
const sqlCellSchema = z.union([z.string(), z.number(), z.null()])
const sqlDisplayColumnSchema = z.object({
name: z.string(),
/** 表达式/聚合列(COUNT(*)、别名)在数据表里无同名列,类型为空串,前端据此隐藏 */
type: z.string(),
})
const sqlResultSetSchema = z.object({
columns: z.array(sqlDisplayColumnSchema),
rows: z.array(z.array(sqlCellSchema)),
total_rows: z.number().int().nonnegative(),
truncated: z.boolean(),
})
export const sqlDisplayTableSchema = sqlResultSetSchema.extend({
name: z.string(),
/** 被标准答案 DROP 的表:条目用初始数据补齐、rows 清空,前端提示「表已删除」 */
dropped: z.boolean().optional(),
})
export const sqlDisplaySchema = z.object({
tables: z.array(sqlDisplayTableSchema),
// query 题给结果集,modify 题给改动后的表 —— 两种形态,前端按有没有
// changed_tables 分支
expected: z.union([sqlResultSetSchema, z.object({ changed_tables: z.array(sqlDisplayTableSchema) })]),
})
/**
* AST 代码要求。同一个形状原来在**三个地方**各写了一份,三份都不一样:
* apps/api/src/judge/ast.ts 的 AstRule(判题机真读的那份,九个字段)、
* apps/web/src/utils/types.ts 的 AstRules(少了 label / exact / outer / inner)、
* AstRulesEditor.vue 里的本地 AstRule(少了 outer / inner)。
* 编辑器写得出 label / exact,题目类型却描述不了它们。现在以这里为准。
*
* 除 engine 外全部可选:判题机每条规则只读自己那几个字段
* (见 ast.ts 的 evaluateRule),缺了就走默认文案。
*/
export const astRuleEngineSchema = z.enum([
'must_exist_node',
'must_not_exist_node',
'count_node',
'must_call_function',
'must_not_call_function',
'count_function_call',
'must_call_method',
'must_not_call_method',
'must_use_operator',
// 只有 Python 有关键字参数:「调用 print() 时必须用 sep 参数」
'must_use_keyword',
// 判题机实现了,但后台编辑器还没有对应的选项,目前只能手工造数据用上
'must_have_nesting',
])
export const astRuleSchema = z.object({
engine: astRuleEngineSchema,
/** 检查目标:节点类型 / 函数名 / 方法名 / 运算符,按 engine 而定 */
target: z.string().optional(),
/** must_have_nesting 专用:外层、内层节点 */
outer: z.string().optional(),
inner: z.string().optional(),
/** must_use_keyword 专用:参数名(target 是函数名) */
keyword: z.string().optional(),
/** 展示用的中文名,缺省回落到 target */
label: z.string().optional(),
/** 自定义提示。生产库里存的是空串而不是缺键,判题机按 `||` 回落到默认文案 */
message: z.string().optional(),
/** count_* 引擎的次数约束 */
exact: z.number().int().optional(),
min: z.number().int().optional(),
max: z.number().int().optional(),
})
/** 按语言分组:`{ Python: [...], C: [...] }`,键是 languages 里的语言名 */
export const astRulesSchema = z.record(z.string(), z.array(astRuleSchema))
/**
* 一个 target 一条,`label` 给人看(后台下拉、题目页的「要求」),`node` 给判题机
* 拿去比 tree-sitter 的节点类型。
*
* **这两半原来是分在两个包里的两张表** —— 这边 target → 中文名,
* `apps/api/src/judge/ast.ts` 的 mappings 是 target → tree-sitter 节点类型,靠一句
* 「两边必须同增同减」的注释维持。只加一边是静默错判:老师给 C 题选到只有 Python
* 有的 `list_comprehension`,判题机 `mapping[target] ?? target` 拿裸名去比节点类型,
* C 的语法树里永远不存在它,于是「必须使用列表推导式」永远失败、「不能使用
* f-string」永远通过,两头都不报错,只有学生受着。并成一张之后,加 target 而漏配
* 节点类型在结构上就不可能了。
*
* (更早之前 AstRulesEditor.vue 和 ProblemContent.vue 还各手抄过一份中文名,
* 且 C 和 Python 的节点混在一张 15 条的表里铺成后台下拉。)
*/
export interface AstNodeTarget {
/** 后台下拉和题目页展示的中文名 */
label: string
/** tree-sitter 里对应的节点类型,判题机按它 collectNodes */
node: string
}
const C_NODE_TARGETS = {
for_loop: { label: 'for 循环', node: 'for_statement' },
while_loop: { label: 'while 循环', node: 'while_statement' },
do_while: { label: 'do-while 循环', node: 'do_statement' },
if_statement: { label: 'if 条件', node: 'if_statement' },
else_clause: { label: 'else 子句', node: 'else_clause' },
switch_statement: { label: 'switch 语句', node: 'switch_statement' },
case_statement: { label: 'case 分支', node: 'case_statement' },
break: { label: 'break 语句', node: 'break_statement' },
continue: { label: 'continue 语句', node: 'continue_statement' },
return: { label: 'return 语句', node: 'return_statement' },
// 不算 main(判题机 judge/ast.ts 的 nodeMatch 里排掉的),所以不叫「函数定义」
function_definition: { label: '自定义函数', node: 'function_definition' },
assignment: { label: '赋值语句', node: 'assignment_expression' },
struct: { label: '结构体', node: 'struct_specifier' },
include: { label: '#include 指令', node: 'preproc_include' },
} satisfies Record<string, AstNodeTarget>
export const AST_NODE_TARGETS_BY_LANGUAGE: Record<string, Record<string, AstNodeTarget>> = {
C: C_NODE_TARGETS,
/**
* tree-sitter-cpp 继承 tree-sitter-c 的语法,C 那 14 条在 C++ 树里逐个实测通用,
* 所以共用的条目一律**引用** C_NODE_TARGETS 而不是抄一遍 —— 原来 C 的 14 行在
* 契约和判题机两个文件里各抄了两遍(C 一份、C++ 一份),同一份数据四份拷贝。
* 这里逐条列出来是为了保住下拉框的显示顺序(C++ 独有的几条是插在中间的)。
*/
'C++': {
for_loop: C_NODE_TARGETS.for_loop,
range_for_loop: { label: '范围 for 循环', node: 'for_range_loop' },
while_loop: C_NODE_TARGETS.while_loop,
do_while: C_NODE_TARGETS.do_while,
if_statement: C_NODE_TARGETS.if_statement,
else_clause: C_NODE_TARGETS.else_clause,
switch_statement: C_NODE_TARGETS.switch_statement,
case_statement: C_NODE_TARGETS.case_statement,
break: C_NODE_TARGETS.break,
continue: C_NODE_TARGETS.continue,
return: C_NODE_TARGETS.return,
function_definition: C_NODE_TARGETS.function_definition,
class_definition: { label: '类定义', node: 'class_specifier' },
struct: C_NODE_TARGETS.struct,
assignment: C_NODE_TARGETS.assignment,
include: C_NODE_TARGETS.include,
try_except: { label: 'try-catch', node: 'try_statement' },
throw: { label: 'throw 语句', node: 'throw_statement' },
namespace: { label: 'namespace 定义', node: 'namespace_definition' },
template: { label: '模板定义', node: 'template_declaration' },
lambda: { label: 'lambda 表达式', node: 'lambda_expression' },
using: { label: 'using 声明', node: 'using_declaration' },
},
Python: {
for_loop: { label: 'for 循环', node: 'for_statement' },
while_loop: { label: 'while 循环', node: 'while_statement' },
if_statement: { label: 'if 条件', node: 'if_statement' },
elif_clause: { label: 'elif 子句', node: 'elif_clause' },
else_clause: { label: 'else 子句', node: 'else_clause' },
break: { label: 'break 语句', node: 'break_statement' },
continue: { label: 'continue 语句', node: 'continue_statement' },
return: { label: 'return 语句', node: 'return_statement' },
function_definition: { label: '函数定义', node: 'function_definition' },
class_definition: { label: '类定义', node: 'class_definition' },
assignment: { label: '赋值语句', node: 'assignment' },
try_except: { label: 'try-except', node: 'try_statement' },
with_statement: { label: 'with 语句', node: 'with_statement' },
import: { label: 'import 语句', node: 'import_statement' },
import_from: { label: 'from-import 语句', node: 'import_from_statement' },
list_comprehension: { label: '列表推导式', node: 'list_comprehension' },
list_literal: { label: '列表', node: 'list' },
dict_literal: { label: '字典', node: 'dictionary' },
set_literal: { label: '集合', node: 'set' },
/**
* f-string 认的是 `interpolation`(`f"{x}"` 里的 `{x}`),不是 `format_string` ——
* 这个版本的 tree-sitter-python 里**没有** format_string 这种节点,f-string 是
* 一个 `string`,靠 `string_start` 为 `f"` 和内部的 interpolation 子节点来认。
* 配成 format_string 的那阵子,「不能使用 f-string」从上线起就一直判成通过。
*
* 代价是 `f"abc"` 这种**没有占位符**的 f-string 认不出来(它确实不含
* interpolation)。没占位符的 f-string 本来也没有意义,而且比起「一个都认不出来」
* 这已经是严格的改善。实测 `%` 格式化、`.format()`、普通字符串、字符串拼接
* 都不会误伤。
*/
f_string: { label: 'f-string', node: 'interpolation' },
},
}
/**
* 运算符 target → 该语言里的实际写法。逻辑名 `and` / `or` / `not` 在 C 里写作
* `&&` / `||` / `!`,文案要翻(否则 C 题的学生看到「必须使用 and 运算符」,而 C 里
* 根本没有 `and` 这个词),判题机也正好拿同一个值去比节点类型 —— 所以这张表**一份
* 两用**,不像节点那样需要两个字段。原来判题机的 mappings 里还抄了一份非恒等的
* (`and`→`&&` 那三条),取值逐个相同,纯属重复。
*
* 恒等的条目(`+`、`==` …)写全是因为这张表同时是后台下拉的选项来源。
*/
const C_OPERATOR_TARGETS = {
'+': '+',
'-': '-',
'*': '*',
'/': '/',
'%': '%',
'+=': '+=',
'-=': '-=',
'*=': '*=',
'/=': '/=',
'%=': '%=',
'++': '++',
'--': '--',
'==': '==',
'!=': '!=',
'>': '>',
'>=': '>=',
'<': '<',
'<=': '<=',
and: '&&',
or: '||',
not: '!',
'&': '&',
'|': '|',
}
export const AST_OPERATOR_TARGETS_BY_LANGUAGE: Record<string, Record<string, string>> = {
C: C_OPERATOR_TARGETS,
// `<<` / `>>` 对 C++ 主要是 cout/cin 的流运算符(位移是同一个 token)
'C++': { ...C_OPERATOR_TARGETS, '<<': '<<', '>>': '>>' },
Python: {
'+': '+',
'-': '-',
'*': '*',
'/': '/',
'//': '//',
'%': '%',
'**': '**',
'+=': '+=',
'-=': '-=',
'*=': '*=',
'/=': '/=',
'%=': '%=',
'==': '==',
'!=': '!=',
'>': '>',
'>=': '>=',
'<': '<',
'<=': '<=',
and: 'and',
or: 'or',
not: 'not',
'&': '&',
'|': '|',
},
}
/**
* 判题机真正能做 AST 检查的语言(C / C++ / Python 三种,就是上面这张表的键)。设计文档写的是
* "支持全部 6 种语言",其余语言 judge/ast.ts 的 loadLanguage 返回 null,规则一条都不会跑。
* 所以后台不给别的语言开 tab,题目页也不把它们的规则展示成「要求」。
*/
export const AST_SUPPORTED_LANGUAGES = Object.keys(AST_NODE_TARGETS_BY_LANGUAGE)
/** 全语言的节点中文名并集,只给拿不到语言的场合做回落。有语言一律走 astNodeLabel() */
export const AST_NODE_TARGET_LABELS: Record<string, string> = Object.fromEntries(
Object.values(AST_NODE_TARGETS_BY_LANGUAGE).flatMap((table) => Object.entries(table).map(([target, entry]) => [target, entry.label])),
)
export function astNodeLabel(target: string, language?: string): string {
const table = language ? AST_NODE_TARGETS_BY_LANGUAGE[language] : undefined
return table?.[target]?.label ?? AST_NODE_TARGET_LABELS[target] ?? target
}
/**
* target → tree-sitter 节点类型。判题机唯一的解析入口 ——
* 节点走上面那张表的 `node`,运算符走运算符表(它的值本身就是要比的 token),
* 都对不上就回落到裸 target(恒等的运算符 `+` / `==` 走的就是这条)。
*/
export function astTargetNodeType(target: string, language: string): string {
return AST_NODE_TARGETS_BY_LANGUAGE[language]?.[target]?.node ?? AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] ?? target
}
export function astOperatorLabel(target: string, language?: string): string {
return (language ? AST_OPERATOR_TARGETS_BY_LANGUAGE[language]?.[target] : undefined) ?? target
}
/**
* count_* 引擎至少要有一个数字约束,否则这条规则恒真 —— rangePassed 三个字段全
* undefined 直接返回 true,描述也退化成光秃秃一个「for 循环」。编辑器切到
* 「出现次数」时会清掉 exact/min/max,老师不填数字就会存出这种规则。
*
* 读取路径(判题、题目页要求)用它把这种规则整条丢掉,而不是让 schema 校验失败:
* astRulesSchema 同时用于**读**后台题目详情,在那儿抛错会让整个详情打不开。
*/
export function astRuleIsMeaningful(rule: { engine: string; exact?: number; min?: number; max?: number }): boolean {
if (!rule.engine.startsWith('count')) return true
return rule.exact !== undefined || rule.min !== undefined || rule.max !== undefined
}
/**
* 下发给**学生**的代码要求。只有渲染要用的两个字段 —— 文案由后端生成,
* engine / target 这些内部字段不出现在响应里。
*
* 旧后端的 ProblemSerializer 没排掉 ast_rules,学生拿到的是规则原文;阶段 3
* 泄露评审刻意收掉了它,同时写明「前端要读具体内容的话得补个专门的字段」——
* 就是这个。收紧保留,展示恢复。
*/
export const astRequirementSchema = z.object({
/** 已经渲染好的中文文案,例如「if 条件 出现 2 次」 */
description: z.string(),
/** 标签配色:必须做 / 不能做 / 次数约束 */
kind: z.enum(['require', 'forbid', 'count']),
})
/** 按语言分组,与 astRulesSchema 同一套键 */
export const astRequirementsSchema = z.record(z.string(), z.array(astRequirementSchema))
/**
* 挑战:做对之后,同一道题加一条写法上的限制再做一遍(「整首歌只用 1 次 print」)。
* 判定沿用 AST 规则,几条规则同时满足才算做到 —— 单写「用 while」的话学生留着原来的
* for、再补一个空 while 就过了,所以常配成「用 while + 不用 for」。
*
* 和 astRules 一样按语言分组。`id` 是谁做到了(challenge_success)的记账键:
* 规则一改就换新 id,旧的做到记录跟着旧规则作废,只改说法不换。
* `scannedAt` 是后端记的「已经拿历史上做对的代码补过一遍记录」,见 services/challenge.ts。
*/
export const challengeSchema = z.object({
id: z.string(),
/** 给学生看的说法,例如「整首歌只用 1 次 print」 */
wording: z.string(),
rules: z.array(astRuleSchema),
scannedAt: z.string().optional(),
})
export const problemChallengesSchema = z.record(z.string(), z.array(challengeSchema))
/** 挑战说法的长度上限:要在结果页签的一行里放得下 */
export const CHALLENGE_WORDING_MAX = 24
/** 一条挑战最多几条规则、一道题一种语言最多几条挑战 */
export const CHALLENGE_RULES_MAX = 4
export const CHALLENGES_PER_LANGUAGE_MAX = 4
/** 我做到了一条挑战:本班、全站各是第几个做到的 */
export const challengeMineSchema = z.object({
at: z.string(),
siteRank: z.number().int(),
/** 没有班级(老师、自己注册的号)时为 null */
classRank: z.number().int().nullable(),
})
/** 下发给学生的一条挑战:只有说法和人数,规则原文不出去(同 astRequirements) */
export const challengeItemSchema = z.object({
id: z.string(),
wording: z.string(),
/** 全站做到的人数 */
doneCount: z.number().int(),
mine: challengeMineSchema.nullable(),
})
/** GET /challenges:几道题、某种语言下的挑战 */
export const problemChallengeGroupSchema = z.object({
problemId: z.string(),
title: z.string(),
/** 这种语言做对过这道题的人数(「642 个做对的人里 62 人做到」的 642) */
acCount: z.number().int(),
/** 我用这种语言做对过没有:没做对的挑战不给接 */
solved: z.boolean(),
challenges: z.array(challengeItemSchema),
})
export const challengeListSchema = z.array(problemChallengeGroupSchema)
export const problemDetailSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
description: z.string(),
inputDescription: z.string(),
outputDescription: z.string(),
samples: z.array(
z.object({
input: z.string(),
output: z.string(),
}),
),
hint: z.string().nullable(),
languages: z.array(problemLanguageSchema),
/**
* 语言 → 代码模板。**用 partialRecord 让键受语言联合约束** ——
* 原来这里是 `z.record(z.string(), z.string())`,等价于 `Record<string, string>`,
* 前端按语言查模板时拿不到任何键名保护(`template["Pytho3"]` 也是合法表达式)。
*
* partialRecord 而非 record:没配模板的语言不该出现该键(`template: {}` 是常态),
* 用 record 会要求每一个语言键都存在。
*/
template: z.partialRecord(problemLanguageSchema, z.string()),
createTime: z.string(),
lastUpdateTime: z.string().nullable(),
timeLimit: z.number().int(),
memoryLimit: z.number().int(),
difficulty: maskedProblemDifficultySchema,
source: z.string().nullable(),
prompt: z.string().nullable(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
statisticInfo: z.record(z.string(), z.unknown()),
contestId: z.number().int().nullable(),
tags: z.array(z.string()),
createdBy: z.object({
id: z.number().int(),
username: z.string(),
realName: z.string().nullable(),
}),
myStatus: z.number().int().nullable(),
myFailedCount: z.number().int(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
mermaidCode: z.string().nullable(),
flowchartData: z.record(z.string(), z.unknown()).nullable(),
flowchartHint: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(),
sqlDisplay: sqlDisplaySchema.nullable(),
// 代码要求(AST 规则的展示投影)。规则原文不下发给学生,见 astRequirementSchema
astRequirements: astRequirementsSchema.nullable(),
})
export type ProblemDetail = z.infer<typeof problemDetailSchema>
export const problemListItemSchema = z.object({
id: z.number().int(),
_id: z.string(),
title: z.string(),
submissionNumber: z.number().int(),
acceptedNumber: z.number().int(),
difficulty: maskedProblemDifficultySchema,
createdBy: sampleUserSchema,
tags: z.array(z.string()),
contestId: z.number().int().nullable(),
allowFlowchart: z.boolean(),
showFlowchart: z.boolean(),
hasAstRules: z.boolean(),
/** SQL 题(可选语言里有 SQL):写 SQL 语句查数据库,和写 C / Python 是两回事,列表里单独标出来 */
isSql: z.boolean(),
/** 0 = 做对了。画流程图的题,流程图评到 A / S 也给 0(和专题、课堂条一个口径) */
myStatus: z.number().int().nullable(),
/**
* 做对的人 / 交过的人,和题目页「统计」页签同一个口径(代码提交、不算还在判的)。
* 代替原来的提交数和按提交算的通过率:1044 按提交算只有 69%,按人算 817 / 826
*/
solvedUsers: z.number().int(),
triedUsers: z.number().int(),
})
export const problemListSchema = paginatedSchema(problemListItemSchema)
/**
* 相似题推荐的一条。`sharedTags` 是它和原题共有的标签(知识点在前、主题在后),
* 学生不点开就知道练的是什么
*/
export const similarProblemSchema = problemListItemSchema.extend({
sharedTags: z.array(z.string()),
})
/** 题目列表「全部类型」下拉:SQL / 有参考图 / 画流程图 / 语法要求 */
export const problemTypeFilterSchema = z.enum(['sql', 'reference', 'flowchart', 'ast'])
/**
* 题目列表的排序:空串 = 最新创建、create_time = 最早创建,
* ±submission_number / ±accepted_number 是做过 / 做对的**人数**,带 - 是从多到少
*/
export const problemListSortSchema = z.enum(['', 'create_time', '-submission_number', 'submission_number', '-accepted_number', 'accepted_number'])
/**
* 题目列表顶上那行和左栏的进度。**只说做到了什么**:做对几道、这周新做对几道、
* 离哪个成就最近;没做对的只给一个数(「有 n 道差一点」),不列出来。
*/
export const problemProgressSchema = z.object({
/** 做对的题数,和成就的「AC 题目数」同一个数(不含比赛),这样「再做对 n 道」加得上 */
solved: z.number().int(),
/** 本周(东八区周一起)新做对几道 */
weekSolved: z.number().int(),
/** 连续几周都有新做对(到本周或上周为止;断了就是 0) */
weekStreak: z.number().int(),
/** 交过、还没做对的公开题。只在点「有 n 道差一点」时弹出来看,不常驻在页面上 */
almost: z.array(z.object({ _id: z.string(), title: z.string() })),
/** 标签 id → 这个标签下做对了几道(只数公开可见的题) */
byTag: z.record(z.string(), z.number().int()),
/** 离得最近的下一个成就;全拿到了、或者没有能提示的为 null */
next: z
.object({
name: z.string(),
icon: z.string(),
rarity: achievementRaritySchema,
metric: z.string(),
threshold: z.number().int(),
progress: z.number().int(),
})
.nullable(),
})
/**
* 标签分两类:`knowledge` 是知识点(循环结构、字符串……),`theme` 是题目的
* 题材(LOL、周杰伦、哈利波特……)。两类原来混在一起,题目列表上四十多个标签
* 挤成一片,学生想按知识点找题得在一堆游戏名里挑。新标签默认算知识点。
*/
export const tagCategorySchema = z.enum(['knowledge', 'theme'])
export const tagSchema = z.object({
id: z.number().int(),
name: z.string(),
category: tagCategorySchema,
problemCount: z.number().int().nonnegative(),
})
export const problemAuthorSchema = z.object({
username: z.string(),
problemCount: z.number().int().nonnegative(),
})
const failureCountSchema = z.object({ result: judgeStatusSchema, count: z.number().int() })
/**
* 题目页「统计」页签。全部**按人**算,不按提交条数 —— 原来的「通过数」是 AC 条数
* (3092 是 219 条、202 个人),「通过率」是按提交算的,学生看了不知道这题难不难。
* 原来的「击败用户」(分母是近两年所有登录过的人)、饼图、历年 AC 率一起去掉了。
*/
export const problemStatsSchema = z.object({
/** 比赛还没结束(又不是比赛管理员):别人的情况一律不给,下面的数字都是 0 / 空 */
locked: z.boolean(),
/** 交过的人 */
tried: z.number().int(),
solved: z.number().int(),
/** 做对的人用了几次:1 次 / 2–3 次 / 4 次以上 */
tries: z.object({ one: z.number().int(), few: z.number().int(), many: z.number().int() }),
/** 没通过的提交按结果分,多的在前(不含判题中) */
failures: z.array(failureCountSchema),
/** 我自己:交了几次、做对没有。没登录为 null */
me: z.object({ attempts: z.number().int(), solved: z.boolean() }).nullable(),
/** 学生自己班这题 x / y 人做对(y 是班里交过的人)。没班级、比赛里为 null */
myClass: z.object({ className: z.string(), tried: z.number().int(), solved: z.number().int() }).nullable(),
/**
* 老师:各班做这道题的情况,和数据统计「按班级汇总」**同一个口径**(只算学生的提交;
* 做完 / 班级人数、交了没对、没交、正确率、最近一次),交过的人多的在前。
* 两边数字要一模一样 —— 老师从这里点过去,看到的数「变了」就不会再信任何一边。
* 学生、比赛里为 null
*/
classes: z
.array(
z.object({
/** null = 没有班级的号(数据统计那边写「没有班级」) */
className: z.string().nullable(),
/** 班里的学生数(学生角色、没禁用);没有班级的那一行就是交过的人数 */
classSize: z.number().int(),
solved: z.number().int(),
/** 交了但没做对的人 */
unsolved: z.number().int(),
/** 班里还没交过的人 */
untouched: z.number().int(),
/** 提交正确率,分母不算还在判的;一条都没判完为 null */
correctRate: z.number().nullable(),
lastTime: z.string(),
}),
)
.nullable(),
})
export type AstRuleEngine = z.infer<typeof astRuleEngineSchema>
export type AstRule = z.infer<typeof astRuleSchema>
export type AstRules = z.infer<typeof astRulesSchema>
export type AstRequirement = z.infer<typeof astRequirementSchema>
export type AstRequirements = z.infer<typeof astRequirementsSchema>
export type Challenge = z.infer<typeof challengeSchema>
export type ProblemChallenges = z.infer<typeof problemChallengesSchema>
export type ChallengeMine = z.infer<typeof challengeMineSchema>
export type ChallengeItem = z.infer<typeof challengeItemSchema>
export type ProblemChallengeGroup = z.infer<typeof problemChallengeGroupSchema>
export type ChallengeList = z.infer<typeof challengeListSchema>
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema>
export type SimilarProblem = z.infer<typeof similarProblemSchema>
export type ProblemTypeFilter = z.infer<typeof problemTypeFilterSchema>
export type ProblemListSort = z.infer<typeof problemListSortSchema>
export type ProblemProgress = z.infer<typeof problemProgressSchema>
export type Tag = z.infer<typeof tagSchema>
export type TagCategory = z.infer<typeof tagCategorySchema>
export type ProblemAuthor = z.infer<typeof problemAuthorSchema>
export type SqlConfig = z.infer<typeof sqlConfigSchema>
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
export type SqlDisplayTable = z.infer<typeof sqlDisplayTableSchema>
export type SqlDisplayColumn = z.infer<typeof sqlDisplayColumnSchema>
export type ProblemStats = z.infer<typeof problemStatsSchema>