diff --git a/packages/hydrooj/src/error.ts b/packages/hydrooj/src/error.ts index 96945e63b5..aa63a519a7 100644 --- a/packages/hydrooj/src/error.ts +++ b/packages/hydrooj/src/error.ts @@ -109,6 +109,7 @@ export const ProblemAlreadyExistError = Err('ProblemAlreadyExistError', Forbidde export const ProblemAlreadyUsedByContestError = Err('ProblemAlreadyUsedByContestError', ForbiddenError, 'Problem {0} is already used by contest {1}.'); export const ProblemNotAllowPretestError = Err('ProblemNotAllowPretestError', ForbiddenError, 'This {0} is not allow run pretest.'); export const ProblemNotAllowLanguageError = Err('ProblemNotAllowSubmitError', ForbiddenError, 'This language is not allow to submit.'); +export const ProblemLockError = Err('ProblemLockError', ForbiddenError, 'Lock Error: {0}'); export const HackRejudgeFailedError = Err('HackRejudgeFailedError', BadRequestError, 'Cannot rejudge a hack record.'); export const CannotDeleteSystemDomainError = Err('CannotDeleteSystemDomainError', BadRequestError, 'You are not allowed to delete system domain.'); diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 6d633b95b6..88109587c7 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -9,7 +9,8 @@ import { import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, - InvalidTokenError, NotAssignedError, PermissionError, ValidationError, + InvalidTokenError, NotAssignedError, PermissionError, ProblemLockError, + ValidationError, } from '../error'; import { ScoreboardConfig, Tdoc } from '../interface'; import paginate from '../lib/paginate'; @@ -640,6 +641,20 @@ export class ContestUserHandler extends ContestManagementBaseHandler { this.back(); } } + +export class ContestProblemLockHandler extends Handler { + @param('tid', Types.ObjectId) + @param('pid', Types.UnsignedInt) + async post(domainId: string, tid: ObjectId, pid: number) { + const lockList = await contest.getLockedList(domainId, tid); + if (!lockList) throw new ProblemLockError('This contest is not lockable.'); + if (lockList[pid].includes(this.user._id)) throw new ProblemLockError('This problem has Locked before.'); + lockList[pid].push(this.user._id); + await contest.updateLockedList(domainId, tid, lockList); + this.back(); + } +} + export async function apply(ctx) { ctx.Route('contest_create', '/contest/create', ContestEditHandler); ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); @@ -652,4 +667,5 @@ export async function apply(ctx) { ctx.Route('contest_code', '/contest/:tid/code', ContestCodeHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_file_download', '/contest/:tid/file/:filename', ContestFileDownloadHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_user', '/contest/:tid/user', ContestUserHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_lock_problem', '/contest/:tid/lock', ContestProblemLockHandler, PERM.PERM_VIEW_CONTEST); } diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index b47f00c838..8a0dd23aca 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -10,7 +10,8 @@ import { BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, FileLimitExceededError, HackFailedError, NoProblemError, NotFoundError, PermissionError, ProblemAlreadyExistError, ProblemAlreadyUsedByContestError, ProblemConfigError, - ProblemIsReferencedError, ProblemNotAllowLanguageError, ProblemNotAllowPretestError, ProblemNotFoundError, + ProblemIsReferencedError, ProblemLockError, + ProblemNotAllowLanguageError, ProblemNotAllowPretestError, ProblemNotFoundError, RecordNotFoundError, SolutionNotFoundError, ValidationError, } from '../error'; import { @@ -479,7 +480,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { if (typeof this.pdoc.config === 'string') throw new ProblemConfigError(); } - async get() { + async get(domainId: string, tid?: ObjectId) { this.response.template = 'problem_submit.html'; const langRange = (typeof this.pdoc.config === 'object' && this.pdoc.config.langs) ? Object.fromEntries(this.pdoc.config.langs.map((i) => [i, setting.langs[i]?.display || i])) @@ -488,6 +489,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { langRange, pdoc: this.pdoc, udoc: this.udoc, + tdoc: this.tdoc, title: this.pdoc.title, page_name: this.tdoc ? this.tdoc.rule === 'homework' @@ -503,6 +505,12 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { @param('input', Types.String, true) @param('tid', Types.ObjectId, true) async post(domainId: string, lang: string, code: string, pretest = false, input = '', tid?: ObjectId) { + if (tid) { + const tdoc = await contest.get(domainId, tid); + if (tdoc.rule === 'cf' && tdoc.lockedList[this.pdoc.docId].includes(this.user._id)) { + throw new ProblemLockError('You have locked this problem.'); + } + } const config = this.pdoc.config; if (typeof config === 'string' || config === null) throw new ProblemConfigError(); if (['submit_answer', 'objective'].includes(config.type)) { @@ -570,7 +578,7 @@ export class ProblemHackHandler extends ProblemDetailHandler { if (!this.rdoc || this.rdoc.pid !== this.pdoc.docId || this.rdoc.contest?.toString() !== tid?.toString()) throw new RecordNotFoundError(domainId, rid); if (tid) { - if (this.tdoc.rule !== 'codeforces') throw new HackFailedError('This contest is not hackable.'); + if (this.tdoc.rule !== 'cf') throw new HackFailedError('This contest is not hackable.'); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); } if (this.rdoc.uid === this.user._id) throw new HackFailedError('You cannot hack your own submission'); diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 9eae08f921..ba0bcce508 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -58,7 +58,7 @@ class RecordListHandler extends ContestDetailBaseHandler { this.tdoc = tdoc; if (!tdoc) throw new ContestNotFoundError(domainId, pid); if (!contest.canShowScoreboard.call(this, tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - if (!contest[q.uid === this.user._id ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true)) { + if (!contest[q.uid === this.user._id ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true, pid ? await problem.get(domainId, pid) : null)) { throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } if (!(await contest.getStatus(domainId, tid, this.user._id))?.attend) { @@ -152,7 +152,7 @@ class RecordDetailHandler extends ContestDetailBaseHandler { } else if (rdoc.contest) { this.tdoc = await contest.get(domainId, rdoc.contest); let canView = this.user.own(this.tdoc); - canView ||= contest.canShowRecord.call(this, this.tdoc); + canView ||= contest.canShowRecord.call(this, this.tdoc, true, await problem.get(domainId, rdoc.pid)); canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && rdoc.uid === this.user._id; if (!canView && rdoc.uid !== this.user._id) throw new PermissionError(rid); canViewDetail = canView; diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 9d0f77c689..2bd243c1bf 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -407,6 +407,9 @@ export interface Tdoc { submitAfterAccept: boolean; showScoreboard: (tdoc: Tdoc<30>, now: Date) => boolean; showSelfRecord: (tdoc: Tdoc<30>, now: Date) => boolean; - showRecord: (tdoc: Tdoc<30>, now: Date) => boolean; + showRecord: (tdoc: Tdoc<30>, now: Date, user?: User, pdoc?: ProblemDoc) => boolean; stat: (this: ContestRule, tdoc: Tdoc<30>, journal: any[]) => ContestStat & T; scoreboardHeader: ( this: ContestRule, config: ScoreboardConfig, _: (s: string) => string, diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 0d60214e21..d955a865c1 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -14,7 +14,7 @@ import * as bus from '../service/bus'; import type { Handler } from '../service/server'; import { PERM, STATUS, STATUS_SHORT_TEXTS } from './builtin'; import * as document from './document'; -import problem from './problem'; +import problem, { ProblemDoc } from './problem'; import user, { User } from './user'; interface AcmJournal { @@ -502,6 +502,98 @@ const ledo = buildContestRule({ }, }, oi); +const cf = buildContestRule({ + TEXT: 'Codeforces', + check: () => { }, + submitAfterAccept: false, + showScoreboard: (tdoc, now) => now > tdoc.beginAt, + showSelfRecord: () => true, + showRecord: (tdoc, now, user, pdoc) => { + if (now > tdoc.endAt) return true; + if (pdoc && tdoc.lockedList[pdoc.docId].includes(user._id)) return true; + return false; + }, + stat(tdoc, journal) { + const ntry = Counter(); + const hackSucc = Counter(); + const hackFail = Counter(); + const detail = {}; + for (const j of journal) { + if ([STATUS.STATUS_COMPILE_ERROR, STATUS.STATUS_FORMAT_ERROR].includes(j.status)) continue; + // if (this.submitAfterAccept) continue; + if (STATUS.STATUS_ACCEPTED !== j.status) ntry[j.pid]++; + if ([STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL].includes(j.status)) { + if (j.status === STATUS.STATUS_HACK_SUCCESSFUL) detail[j.pid].hackSucc++; + else detail[j.pid].hackFail++; + continue; + } + const timePenaltyScore = Math.round(Math.max(j.score * 100 + - ((j.rid.getTimestamp().getTime() - tdoc.beginAt.getTime()) * (j.score * 100)) / (250 * 60000), + j.score * 100 * 0.3)); + const penaltyScore = Math.max(timePenaltyScore - 50 * (ntry[j.pid]), 0); + if (!detail[j.pid] || detail[j.pid].penaltyScore < penaltyScore) { + detail[j.pid] = { + ...j, + penaltyScore, + timePenaltyScore, + ntry: ntry[j.pid], + hackFail: hackFail[j.pid], + hackSucc: hackSucc[j.pid], + }; + } + } + let score = 0; + let originalScore = 0; + for (const pid of tdoc.pids) { + if (!detail[pid]) continue; + detail[pid].penaltyScore -= 50 * detail[pid].hackFail; + detail[pid].penaltyScore += 100 * detail[pid].hackSucc; + score += detail[pid].penaltyScore; + originalScore += detail[pid].score; + } + return { + score, originalScore, detail, + }; + }, + async scoreboardRow(config, _, tdoc, pdict, udoc, rank, tsdoc, meta) { + const tsddict = tsdoc.detail || {}; + const row: ScoreboardRow = [ + { type: 'rank', value: rank.toString() }, + { type: 'user', value: udoc.uname, raw: tsdoc.uid }, + ]; + if (config.isExport) { + row.push({ type: 'email', value: udoc.mail }); + row.push({ type: 'string', value: udoc.school || '' }); + row.push({ type: 'string', value: udoc.displayName || '' }); + row.push({ type: 'string', value: udoc.studentId || '' }); + } + row.push({ + type: 'total_score', + value: tsdoc.score || 0, + hover: tsdoc.score !== tsdoc.originalScore ? _('Original score: {0}').format(tsdoc.originalScore) : '', + }); + for (const s of tsdoc.journal || []) { + if (!pdict[s.pid]) continue; + pdict[s.pid].nSubmit++; + if (s.status === STATUS.STATUS_ACCEPTED) pdict[s.pid].nAccept++; + } + for (const pid of tdoc.pids) { + row.push({ + type: 'record', + value: tsddict[pid]?.penaltyScore || '', + hover: tsddict[pid]?.penaltyScore + ? `${tsddict[pid].timePenaltyScore}, -${tsddict[pid].ntry}, +${tsddict[pid].hackSucc} , -${tsddict[pid].hackFail}` + : '', + raw: tsddict[pid]?.rid, + style: tsddict[pid]?.status === STATUS.STATUS_ACCEPTED && tsddict[pid]?.rid.getTimestamp().getTime() === meta?.first?.[pid] + ? 'background-color: rgb(217, 240, 199);' + : undefined, + }); + } + return row; + }, +}, oi); + const homework = buildContestRule({ TEXT: 'Assignment', hidden: true, @@ -652,7 +744,7 @@ const homework = buildContestRule({ }); export const RULES: ContestRules = { - acm, oi, homework, ioi, ledo, strictioi, + acm, oi, homework, ioi, ledo, strictioi, cf, }; function _getStatusJournal(tsdoc) { @@ -672,7 +764,7 @@ export async function add( RULES[rule].check(data); await bus.parallel('contest/before-add', data); const res = await document.add(domainId, content, owner, document.TYPE_CONTEST, null, null, null, { - ...data, title, rule, beginAt, endAt, pids, attend: 0, rated, + ...data, title, rule, beginAt, endAt, pids, attend: 0, rated, lockedList: pids.reduce((acc, curr) => ({ ...acc, [curr]: [] }), {}), }); await bus.parallel('contest/add', data, res); return res; @@ -848,8 +940,8 @@ export function canViewHiddenScoreboard(this: { user: User }, tdoc: Tdoc<30>) { return this.user.hasPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } -export function canShowRecord(this: { user: User }, tdoc: Tdoc<30>, allowPermOverride = true) { - if (RULES[tdoc.rule].showRecord(tdoc, new Date())) return true; +export function canShowRecord(this: { user: User }, tdoc: Tdoc<30>, allowPermOverride = true, pdoc?: ProblemDoc) { + if (RULES[tdoc.rule].showRecord(tdoc, new Date(), this.user, pdoc)) return true; if (allowPermOverride && canViewHiddenScoreboard.call(this, tdoc)) return true; return false; } @@ -890,6 +982,18 @@ export const statusText = (tdoc: Tdoc, tsdoc?: any) => ( ? 'Live...' : 'Done'); +export async function getLockedList(domainId: string, tid: ObjectId) { + const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid); + if (tdoc.rule !== 'cf') return null; + return tdoc.lockedList; +} + +export async function updateLockedList(domainId: string, tid: ObjectId, $lockList: any) { + const tdoc = await document.get(domainId, document.TYPE_CONTEST, tid); + tdoc.lockedList = $lockList; + edit(domainId, tid, tdoc); +} + global.Hydro.model.contest = { RULES, add, @@ -922,4 +1026,6 @@ global.Hydro.model.contest = { isLocked, isExtended, statusText, + getLockedList, + updateLockedList, }; diff --git a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx index 0efbc32a95..2c3671274f 100644 --- a/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx +++ b/packages/ui-default/components/scratchpad/ScratchpadToolbarContainer.jsx @@ -123,7 +123,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadTool )} this.props.postSubmit(this.props)} data-global-hotkey="f10" diff --git a/packages/ui-default/templates/contest_problemlist.html b/packages/ui-default/templates/contest_problemlist.html index 84374a0c37..79148e80fe 100644 --- a/packages/ui-default/templates/contest_problemlist.html +++ b/packages/ui-default/templates/contest_problemlist.html @@ -49,6 +49,19 @@

{{ _('Problems') }}

{{ String.fromCharCode(65+loop.index0) }}  {{ pdict[pid].title }} + {% if tdoc.rule === "cf" %} + Hack +
+ + +
+ {% endif %} {%- endfor -%} diff --git a/packages/ui-default/templates/problem_submit.html b/packages/ui-default/templates/problem_submit.html index 8fad7ceb44..f9d6d4e590 100644 --- a/packages/ui-default/templates/problem_submit.html +++ b/packages/ui-default/templates/problem_submit.html @@ -1,6 +1,9 @@ {% extends "layout/basic.html" %} {% block content %} {{ set(UiContext, 'pdoc', pdoc) }} +{% if tdoc %} + {{ set(UiContext, 'tdoc', tdoc) }} +{% endif %}
@@ -26,7 +29,11 @@

{{ _('Submit to Judge') }}

- + {% if not tdoc or not tdoc.lockedList[pdoc.docId].includes(handler.user._id) %} + + {% else %} + + {% endif %}
diff --git a/packages/ui-default/templates/record_detail.html b/packages/ui-default/templates/record_detail.html index 2c5c24693b..d6fee3c7e5 100644 --- a/packages/ui-default/templates/record_detail.html +++ b/packages/ui-default/templates/record_detail.html @@ -52,9 +52,15 @@

{{ _('Information') }}