diff --git a/packages/hydrojudge/package.json b/packages/hydrojudge/package.json index 6825f8540..d9bf6181b 100644 --- a/packages/hydrojudge/package.json +++ b/packages/hydrojudge/package.json @@ -1,7 +1,7 @@ { "name": "@hydrooj/hydrojudge", "bin": "bin/hydrojudge.js", - "version": "2.20.7", + "version": "2.21.0", "main": "package.json", "author": "undefined ", "repository": "https://github.com/hydro-dev/Hydro.git", diff --git a/packages/hydrojudge/src/cases.ts b/packages/hydrojudge/src/cases.ts index baf38cd5d..d28917de0 100644 --- a/packages/hydrojudge/src/cases.ts +++ b/packages/hydrojudge/src/cases.ts @@ -88,7 +88,7 @@ export default async function readCases(folder: string, cfg: Record const checkFile = ensureFile(folder); const result = await readYamlCases(config, checkFile) .catch((e) => { throw changeErrorType(e, FormatError); }); - result.count = result.outputs?.length || Math.sum((result.subtasks || []).map((s) => s.cases.length)); + result.count = Object.keys(result.answers || {}).length || Math.sum((result.subtasks || []).map((s) => s.cases.length)); if (!result.count) { try { result.subtasks = readSubtasksFromFiles(await collectFiles(folder), cfg); diff --git a/packages/hydrojudge/src/judge/interface.ts b/packages/hydrojudge/src/judge/interface.ts index 1ebb1f78b..3f34a6688 100644 --- a/packages/hydrojudge/src/judge/interface.ts +++ b/packages/hydrojudge/src/judge/interface.ts @@ -62,10 +62,7 @@ export interface Config { hack?: string; interactor?: string; - outputs?: { - output: string, - score: number, - }[]; + answers?: Record; } export interface ContextSubTask { diff --git a/packages/hydrojudge/src/judge/objective.ts b/packages/hydrojudge/src/judge/objective.ts index 0d6a374fb..0ac588490 100644 --- a/packages/hydrojudge/src/judge/objective.ts +++ b/packages/hydrojudge/src/judge/objective.ts @@ -1,4 +1,6 @@ +import assert from 'assert'; import { readFile } from 'fs-extra'; +import yaml from 'js-yaml'; import { STATUS } from '@hydrooj/utils/lib/status'; import { Context } from './interface'; @@ -11,27 +13,98 @@ export async function judge({ : ('content' in code) ? code.content.toString().replace(/\r/g, '') : ''; - const outputs = answer.split('\n'); + let answers: { [x: string]: string | string[] } = {}; + try { + answers = yaml.load(answer) as any; + assert(typeof answers === 'object'); + } catch (e) { + return end({ + status: STATUS.STATUS_WRONG_ANSWER, + score: 0, + message: 'Unable to parse answer.', + time: 0, + memory: 0, + }); + } let totalScore = 0; let totalStatus = 0; - for (const i in config.outputs) { - const c = config.outputs[i]; - outputs[i] = outputs[i] || ''; - let status = STATUS.STATUS_WRONG_ANSWER; - let score = 0; - if (outputs[i].trim() === (c.output || c[0]).trim()) { - score = c.score || c[1]; - status = STATUS.STATUS_ACCEPTED; + for (const key in config.answers) { + const [subtaskId, caseId] = key.split('-').map(Number); + const ansInfo = config.answers[key] as [string | string[], number]; + const score = ansInfo[1]; + const baseInfo = { + subtaskId, + id: caseId, + time: 0, + memory: 0, + }; + if (typeof ansInfo[0] === 'string') { + if (ansInfo[0].trim() === (answers[key] as any).trim()) { + totalScore += score; + totalStatus = Math.max(totalStatus, STATUS.STATUS_ACCEPTED); + next({ + status: totalStatus, + case: { + ...baseInfo, + status: STATUS.STATUS_ACCEPTED, + score, + message: 'Correct', + }, + }, +caseId); + } else { + totalStatus = STATUS.STATUS_WRONG_ANSWER; + next({ + status: totalStatus, + case: { + ...baseInfo, + status: STATUS.STATUS_WRONG_ANSWER, + score: 0, + message: 'Incorrect', + }, + }, +caseId); + } + } else { + const stdAns = new Set(ansInfo[0]); + const ans = new Set(answers[key]); + const correct = stdAns.size === ans.size && [...stdAns].every((x) => ans.has(x)); + const partialCorrect = [...stdAns].some((x) => ans.has(x)) && [...ans].every((x) => stdAns.has(x)); + if (correct) { + totalScore += score; + totalStatus = Math.max(totalStatus, STATUS.STATUS_ACCEPTED); + next({ + status: totalStatus, + case: { + ...baseInfo, + status: STATUS.STATUS_ACCEPTED, + score, + message: 'Correct', + }, + }, +caseId); + } else if (partialCorrect) { + totalScore += Math.floor(score / 2); + totalStatus = STATUS.STATUS_WRONG_ANSWER; + next({ + status: totalStatus, + case: { + ...baseInfo, + status: STATUS.STATUS_WRONG_ANSWER, + score: Math.floor(score / 2), + message: 'Partially Correct', + }, + }, +caseId); + } else { + totalStatus = STATUS.STATUS_WRONG_ANSWER; + next({ + status: totalStatus, + case: { + ...baseInfo, + status: STATUS.STATUS_WRONG_ANSWER, + score: 0, + message: 'Incorrect', + }, + }, +caseId); + } } - totalScore += score; - totalStatus = Math.max(status, totalStatus); - next({ - status: totalStatus, - progress: (100 * (+i + 1)) / config.outputs.length, - case: { - status, score, time: 0, memory: 0, message: '', - }, - }, +i + 1); } return end({ status: totalStatus, score: totalScore, time: 0, memory: 0, diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index 816df4d5d..cc6e93ec5 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "3.13.19", + "version": "3.14.0", "bin": "bin/hydrooj.js", "main": "src/loader", "module": "src/loader", diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index c8e29de2b..df67241c5 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -170,7 +170,7 @@ export interface ProblemConfigFile { user_extra_files?: string[]; judge_extra_files?: string[]; detail?: boolean; - outputs?: [string, number][]; + answers?: Record; redirect?: string; cases?: TestCaseConfig[]; subtasks?: SubtaskConfig[]; diff --git a/packages/hydrooj/src/upgrade.ts b/packages/hydrooj/src/upgrade.ts index 19e0adfdc..81614060b 100644 --- a/packages/hydrooj/src/upgrade.ts +++ b/packages/hydrooj/src/upgrade.ts @@ -17,6 +17,7 @@ import * as contest from './model/contest'; import * as discussion from './model/discussion'; import * as document from './model/document'; import domain from './model/domain'; +import MessageModel from './model/message'; import problem from './model/problem'; import RecordModel from './model/record'; import StorageModel from './model/storage'; @@ -715,6 +716,74 @@ const scripts: UpgradeScript[] = [ } return true; }, + async function _62_63() { + const _FRESH_INSTALL_IGNORE = 1; + const uids = new Set(); + await iterateAllDomain(async (ddoc) => { + const pdocs = await problem.getMulti(ddoc._id, { config: /type: objective/ }) + .project({ + config: 1, content: 1, docId: 1, owner: 1, + }).toArray(); + for (const pdoc of pdocs) { + try { + const config = yaml.load(pdoc.config as string) as any; + if (config.type !== 'objective' || !config.outputs) continue; + config.answers = {}; + let cnt = 0; + for (const l of config.outputs) { + cnt++; + config.answers[cnt] = l; + } + + function processSingleLanguage(content: string) { // eslint-disable-line no-inner-declarations + let text = ''; + try { + let scnt = 0; + const doc = yaml.load(content); + if (!(doc instanceof Array)) return content; + for (const s of doc) { + scnt++; + text += `${scnt}. ${s.desc}\n`; + if (s.choices) { + text += `{{ select(${scnt}) }}\n`; + const isPrefixed = s.choices.every((c) => /^[A-Z]\./.test(c)); + let selectionId = 64; + for (const c of s.choices) { + selectionId++; + text += `- ${isPrefixed ? c.replace(/^[A-Z]\./, '') : c}\n`; + if (config.answers[scnt][0] === c) config.answers[scnt][0] = String.fromCharCode(selectionId); + } + } else text += `{{ input(${scnt}) }}\n`; + text += '\n'; + } + } catch (e) { console.error(e); return content; } + return text; + } + + try { + const langs = JSON.parse(pdoc.content); + if (typeof langs === 'object' && !(langs instanceof Array)) { + for (const lang in langs) { + if (typeof langs[lang] !== 'string') continue; + langs[lang] = processSingleLanguage(langs[lang]); + } + await problem.edit(ddoc._id, pdoc.docId, { content: JSON.stringify(langs) }); + } + } catch (e) { + const content = processSingleLanguage(pdoc.content); + await problem.edit(ddoc._id, pdoc.docId, { content }); + } + uids.add(pdoc.owner); + delete config.outputs; + await problem.addTestdata(ddoc._id, pdoc.docId, 'config.yaml', Buffer.from(yaml.dump(config))); + } catch (e) { console.error(e); } + } + }); + for (const uid of uids) { + await MessageModel.send(1, uid, '我们更新了客观题的配置格式,已有题目已自动转换,查看文档获得更多信息。', MessageModel.FLAG_UNREAD); + } + return true; + }, ]; export default scripts; diff --git a/packages/ui-default/components/monaco/languages/yaml.ts b/packages/ui-default/components/monaco/languages/yaml.ts index 84a576182..df6e9a887 100644 --- a/packages/ui-default/components/monaco/languages/yaml.ts +++ b/packages/ui-default/components/monaco/languages/yaml.ts @@ -70,9 +70,16 @@ const problemConfigSchema = { }, additionalProperties: false, }, - outputs: { - type: 'array', - minLength: 1, + answers: { + type: 'object', + patternProperties: { + '^\\d+(-\\d+)?$': { + type: 'array', + minLength: 2, + maxLength: 2, + }, + }, + additionalProperties: false, }, }, additionalProperties: false, diff --git a/packages/ui-default/components/objective-question/index.jsx b/packages/ui-default/components/objective-question/index.jsx deleted file mode 100644 index c810c25a3..000000000 --- a/packages/ui-default/components/objective-question/index.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { sortBy } from 'lodash'; -import request from 'vj/utils/request'; -import Notification from 'vj/components/notification'; - -export default class ObjectiveContainer extends React.PureComponent { - constructor(args) { - super(args); - this.state = {}; - this.onChange = this.onChange.bind(this); - this.onSubmit = this.onSubmit.bind(this); - } - - onChange(ev) { - this.setState({ [ev.target.name]: ev.target.value }); - } - - onSubmit(ev) { - ev.preventDefault(); - const entries = sortBy(Object.entries(this.state).map((i) => [+i[0], i[1]])); - const total = entries[entries.length - 1][0]; - const ans = new Array(total).fill(''); - entries.forEach((i) => ans[i[0]] = i[1]); // eslint-disable-line - request - .post(this.props.target, { - lang: '_', - code: ans.join('\n'), - }) - .then((res) => { - window.location.href = res.url; - }) - .catch((e) => { - Notification.error(e.message); - }); - } - - Textbox(args, name) { - return ( - - ); - } - - Radio(args, name) { - return ( - <> - {name + 1}. {args.desc.split('\n').map((i, index) => { - if (index) return <>
{i}; - return i; - })} - {args.choices.map((i) => ( - - ))} - - ); - } - - render() { - return ( -
- {this.props.panel.map((i, name) => ( -
-
- {i.choices ? this.Radio(i, name) : this.Textbox(i, name)} -
-
- ))} - {document.getElementsByClassName('nav__item--round').length - ? - : } -
- ); - } -} diff --git a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx index 27ab9d6e1..ab8b2955f 100644 --- a/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx +++ b/packages/ui-default/components/problemconfig/ProblemConfigEditor.tsx @@ -74,8 +74,8 @@ export function configYamlFormat(config: ProblemConfigFile) { } }); if (formatConfig.type === 'objective') { - Object.keys(formatConfig).filter((i) => !['type', 'outputs'].includes(i)).forEach((i) => delete formatConfig[i]); - formatConfig.outputs = formatConfig.outputs || []; + Object.keys(formatConfig).filter((i) => !['type', 'answers'].includes(i)).forEach((i) => delete formatConfig[i]); + formatConfig.answers = config.answers || {}; } Object.keys(formatConfig).filter((i) => i.startsWith('__')).forEach((i) => delete formatConfig[i]); return formatConfig; diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index dd2c21f60..544a06755 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.37.25", + "version": "4.38.0", "author": "undefined ", "license": "AGPL-3.0", "main": "hydro.js", diff --git a/packages/ui-default/pages/problem_detail.page.jsx b/packages/ui-default/pages/problem_detail.page.jsx index 5740081c6..e81f1a37f 100644 --- a/packages/ui-default/pages/problem_detail.page.jsx +++ b/packages/ui-default/pages/problem_detail.page.jsx @@ -1,5 +1,5 @@ import yaml from 'js-yaml'; -import ReactDOM, { createRoot } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; import React from 'react'; import { getScoreColor } from '@hydrooj/utils/lib/status'; import { NamedPage } from 'vj/misc/Page'; @@ -7,6 +7,9 @@ import { downloadProblemSet } from 'vj/components/zipDownloader'; import loadReactRedux from 'vj/utils/loadReactRedux'; import delay from 'vj/utils/delay'; import pjax from 'vj/utils/pjax'; +import request from 'vj/utils/request'; +import tpl from 'vj/utils/tpl'; +import i18n from 'vj/utils/i18n'; class ProblemPageExtender { constructor() { @@ -173,32 +176,75 @@ const page = new NamedPage(['problem_detail', 'contest_detail_problem', 'homewor } async function loadObjective() { - try { - let s = ''; - try { - s = JSON.parse(UiContext.pdoc.content); - } catch { - s = UiContext.pdoc.content; + $('.outer-loader-container').show(); + const ans = {}; + let cnt = 0; + const reg = /{{ (input|select|multiselect)\(\d+(-\d+)?\) }}/g; + $('.problem-content .typo').children().each((i, e) => { + if (e.tagName === 'PRE' && !e.children[0].className.includes('#input')) return; + const questions = []; + let q; + while (q = reg.exec(e.innerText)) questions.push(q); // eslint-disable-line no-cond-assign + for (const [info, type] of questions) { + cnt++; + const id = info.replace(/{{ (input|select|multiselect)\((\d+(-\d+)?)\) }}/, '$2'); + if (type === 'input') { + $(e).html($(e).html().replace(info, tpl` +
+ +
+ `)); + } else { + if ($(e).next()[0].tagName !== 'UL') { + cnt--; + return; + } + $(e).html($(e).html().replace(info, '')); + $(e).next('ul').children().each((j, ele) => { + $(ele).after(tpl` +
+ + ${String.fromCharCode(65 + j)}. ${ele.innerHTML} +
+ `); + $(ele).remove(); + }); + } } - if (typeof s === 'object') { - const langs = Object.keys(s); - const f = langs.filter((i) => i.startsWith(UserContext.viewLang)); - if (s[UserContext.viewLang]) s = s[UserContext.viewLang]; - else if (f.length) s = s[f[0]]; - else s = s[langs[0]]; - } - const props = yaml.load(s); - if (!(props instanceof Array)) return; - $('.outer-loader-container').show(); - const { default: Objective } = await import('vj/components/objective-question/index'); - - ReactDOM.createRoot($('.problem-content').get(0)).render( -
- -
, - ); - $('.outer-loader-container').hide(); - } catch (e) { } + }); + if (cnt) { + $('.problem-content .typo').append(document.getElementsByClassName('nav__item--round').length + ? `` + : ``); + $('input.objective-input[type!=checkbox]').on('input', (e) => { + ans[e.target.name] = e.target.value; + }); + $('input.objective-input[type=checkbox]').on('input', (e) => { + if (e.target.checked) { + if (ans[e.target.name] === undefined) ans[e.target.name] = []; + ans[e.target.name].push(e.target.value); + } else { + ans[e.target.name] = ans[e.target.name].filter((v) => v !== e.target.value); + } + }); + $('input[type="submit"]').on('click', (e) => { + e.preventDefault(); + request + .post(UiContext.postSubmitUrl, { + lang: '_', + code: yaml.dump(ans), + }) + .then((res) => { + window.location.href = res.url; + }) + .catch((err) => { + Notification.error(err.message); + }); + }); + } + $('.scratchpad--hide').hide(); + $('.non-scratchpad--hide').hide(); + $('.outer-loader-container').hide(); } async function initChart() { diff --git a/packages/utils/lib/cases.ts b/packages/utils/lib/cases.ts index faa5a0bea..72980e132 100644 --- a/packages/utils/lib/cases.ts +++ b/packages/utils/lib/cases.ts @@ -7,21 +7,24 @@ export default async function readYamlCases(cfg: Record = {}, check judge_extra_files: [], user_extra_files: [], }; - if (cfg.checker) { - if (!cfg.checker.includes('.')) { - config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false); + if (cfg.type === 'objective') { + if (cfg.checker || cfg.interactor) throw new Error('You cannot use checker or interactor for objective questions'); + } else { + if (cfg.checker) { + if (!cfg.checker.includes('.')) { + config.checker = findFileSync(`@hydrooj/hydrojudge/vendor/testlib/checkers/${cfg.checker}.cpp`, false); + } + if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.'); } - if (!config.checker) config.checker = checkFile(cfg.checker, 'Cannot find checker {0}.'); + if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.'); + ['judge', 'user'].forEach((n) => { + const conf = cfg[`${n}_extra_files`]; + if (!conf) return; + if (conf instanceof Array) { + config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`)); + } else throw new Error(`Invalid ${n}_extra_files config.`); + }); } - if (cfg.interactor) config.interactor = checkFile(cfg.interactor, 'Cannot find interactor {0}.'); - ['judge', 'user'].forEach((n) => { - const conf = cfg[`${n}_extra_files`]; - if (!conf) return; - if (conf instanceof Array) { - config[`${n}_extra_files`] = conf.map((file) => checkFile(file, `Cannot find ${n} extra file {0}.`)); - } else throw new Error(`Invalid ${n}_extra_files config.`); - }); - if (cfg.outputs) config.type = cfg.type || 'objective'; if (cfg.cases?.length) { config.subtasks = [{ cases: cfg.cases, @@ -30,7 +33,6 @@ export default async function readYamlCases(cfg: Record = {}, check } if (cfg.time) config.time = parseTimeMS(cfg.time); if (cfg.memory) config.memory = parseMemoryMB(cfg.memory); - if (config.type === 'objective' && !cfg.outputs?.length) throw new Error('outputs config not found'); return Object.assign(cfg, config); } diff --git a/packages/utils/package.json b/packages/utils/package.json index 5515d9607..315530fd8 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/utils", - "version": "1.2.7", + "version": "1.3.0", "description": "hydrooj utils", "main": "package.json", "repository": "https://github.com/hydro-dev/Hydro.git",