feat(挑战): 做对之后可以加一条写法上的限制再做一遍,判题时记下谁做到了

借小丑牌的赌注档:同一道题加一条限制(「整首歌只用 1 次 print」)再做一遍,
给课上做得快的学生找事做(做完一节课的题,中位要空等 13 分钟)。
判定沿用 AST 规则,挑战配在 problem.challenges,做到的记在 challenge_success;
做对的提交顺手判一遍,挑战没做到也照样算做对。没过的规则带上是第几行。
0039 灌进第一批 56 道常用题的 73 条挑战,api 起来后拿历史上做对的代码补一遍谁已经做到了。
出题页能配挑战、从做对的代码里找候选;课堂看板的格子带上挑战状态。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 08:52:50 +08:00
1 parent cc1361e596
commit 0faf78ad8f
17 files changed
+9057 -37

No files matched your search

+55
View File
@@ -11,6 +11,10 @@ import { problemLanguageSchema, runnableLanguageSchema } from "./language"
import {
astRuleSchema,
astRulesSchema,
CHALLENGE_RULES_MAX,
CHALLENGE_WORDING_MAX,
CHALLENGES_PER_LANGUAGE_MAX,
problemChallengesSchema,
problemDifficultySchema,
sqlConfigSchema,
sqlDisplaySchema,
@@ -945,6 +949,7 @@ export const adminProblemSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()),
flowchartHint: z.string().nullable(),
astRules: astRulesSchema.nullable(),
challenges: problemChallengesSchema.nullable(),
answers: z.array(problemAnswerSchema),
prompt: z.string().nullable(),
sqlConfig: sqlConfigSchema.nullable(),
@@ -963,6 +968,21 @@ export const adminProblemSchema = z.object({
*/
export const PROBLEM_TAG_MAX_LENGTH = 32
/**
* 出题页提交的一条挑战。新加的不带 id,后端发;规则改过的后端也换新 id(见契约
* challengeSchema)。规则的语义校验(这种语言有没有这个语法)在后端 astRulesError,同 astRules
*/
export const challengeInputSchema = z.object({
id: z.string().max(32).optional(),
wording: z.string().trim().min(1).max(CHALLENGE_WORDING_MAX),
rules: z.array(astRuleSchema).min(1).max(CHALLENGE_RULES_MAX),
})
export const challengeInputsSchema = z.record(
z.string(),
z.array(challengeInputSchema).max(CHALLENGES_PER_LANGUAGE_MAX),
)
export const createProblemRequestSchema = z.object({
_id: z.string().trim().min(1).max(32),
title: z.string().trim().min(1).max(1024),
@@ -1002,6 +1022,11 @@ export const createProblemRequestSchema = z.object({
flowchartData: z.record(z.string(), z.unknown()).nullable().default(null),
flowchartHint: z.string().nullable().default(null),
astRules: astRulesSchema.nullable().default(null),
/**
* 不带这个键 = 不动原来的挑战。上线那一刻浏览器里还开着旧版出题页,它保存时不带挑战,
* 给个默认 null 的话一保存就把挑战清了;null 才是「清空」
*/
challenges: challengeInputsSchema.nullable().optional(),
// order_sensitive 缺省补 false —— 与旧后端 SQLConfigSerializer 的
// `BooleanField(default=False)` 一致
sqlConfig: sqlConfigSchema
@@ -1122,6 +1147,33 @@ export const astCheckResponseSchema = z.object({
),
})
/**
* 出题页「挑战」的候选:拿这道题历史上做对的代码,找「一批人用另一种写法也做对了」的地方。
* 只是给老师挑的素材 —— 机械找出来的会有坏的(拆掉 elif 换成一串 if 也算「不用 elif」),
* 所以每条都带一份做到的人的真实代码,好不好老师看了再加
*/
export const challengeSuggestionsRequestSchema = z.object({
language: runnableLanguageSchema,
})
export const challengeSuggestionSchema = z.object({
/** 默认说法,老师加上之后可以改 */
wording: z.string(),
rules: z.array(astRuleSchema),
/** 这样做对过的人数 */
did: z.number().int(),
/** 第一次做对时就这样写的人占几成(%);「少用 print」这类没有 */
firstShare: z.number().int().nullable(),
/** 一份做到的人的代码 */
example: z.string(),
})
export const challengeSuggestionsResponseSchema = z.object({
/** 这种语言做对过的人数 */
acCount: z.number().int(),
suggestions: z.array(challengeSuggestionSchema),
})
/** AI 按题面和标准答案想几组测试输入(输出由标准答案在判题机上跑,不让 AI 编) */
export const generateTestInputsRequestSchema = z.object({
description: z.string().max(64 * 1024),
@@ -1180,6 +1232,9 @@ export type GenerateSqlTestCaseResponse = z.infer<typeof generateSqlTestCaseResp
export type TestCaseFiles = z.infer<typeof testCaseFilesSchema>
export type AstCheckRequest = z.input<typeof astCheckRequestSchema>
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
export type ChallengeInput = z.input<typeof challengeInputSchema>
export type ChallengeSuggestion = z.infer<typeof challengeSuggestionSchema>
export type ChallengeSuggestionsResponse = z.infer<typeof challengeSuggestionsResponseSchema>
export type GenerateTestInputsRequest = z.input<typeof generateTestInputsRequestSchema>
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
+7
View File
@@ -88,6 +88,8 @@ export const classBoardProblemSchema = z.object({
title: z.string(),
/** SQL 题不受布置的语言限制:格子数的是 SQL 交的,点进提交列表也按 SQL 筛 */
isSql: z.boolean(),
/** 这道题配了挑战(见 problem.ts 的 challengeSchema):题卡片写挑战成功几人 */
hasChallenges: z.boolean(),
})
export const classBoardCellSchema = z.object({
@@ -128,6 +130,11 @@ export const classBoardCellSchema = z.object({
trail: z.array(z.object({ id: z.string(), result: judgeStatusSchema, createTime: z.string() })),
/** 今天第几次代码提交做对的,1 = 一次就对;今天之前就做对了、今天没靠代码做对的为 null */
acceptedTry: z.number().int().nullable(),
/**
* 挑战:done = 做到了这道题的一条挑战(不限今天);doing = 做对之后今天又交了、还没做到 ——
* 做完了的学生是在挑战还是闲着,老师一眼看得出。没配挑战的题、没做对的格子为 null
*/
challenge: z.enum(["done", "doing"]).nullable(),
})
export const classBoardStudentSchema = z.object({
+62
View File
@@ -355,6 +355,62 @@ export const astRequirementSchema = z.object({
/** 按语言分组,与 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(),
@@ -555,6 +611,12 @@ 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>
+22 -4
View File
@@ -51,6 +51,19 @@ export interface JudgeInfo {
data: JudgeCaseResult[] | null
}
/** 一条 AST 规则的判定结果,judge/ast.ts 的 checkAst 产出 */
export const astResultSchema = z.object({
description: z.string(),
passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual: z.number().optional(),
/**
* 没过的「不能用 X」「X 最多 N 次」:X 出现在学生代码的第几行(1 起,已扣掉题目模板的
* 前置代码)。2026-10 加的,之前的提交没有
*/
lines: z.array(z.number().int()).optional(),
})
/**
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
*
@@ -67,13 +80,17 @@ export const statisticInfoSchema = z.looseObject({
err_info: z.string().optional(),
time_cost: z.number().optional(),
memory_cost: z.number().optional(),
ast_results: z
ast_results: z.array(astResultSchema).optional(),
/**
* 做对的提交拿这道题的每条挑战(problem.challenges 里这种语言的)各判一遍,
* judge/run.ts 写。比赛提交不判。前端在挑战中时按 id 取它正在挑战的那条
*/
challenges: z
.array(
z.object({
description: z.string(),
id: z.string(),
passed: z.boolean(),
/** count_* 规则实际数到的次数,判题机只在这两个引擎上写 */
actual: z.number().optional(),
results: z.array(astResultSchema),
}),
)
.optional(),
@@ -616,6 +633,7 @@ export type TrialRunResponse =
| { status: "too-much-output" }
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type AstResultInfo = z.infer<typeof astResultSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.input<typeof createSubmissionRequestSchema>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>