Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

core&ui: Add Codeforces contest mode #568

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/hydrooj/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
18 changes: 17 additions & 1 deletion packages/hydrooj/src/handler/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
14 changes: 11 additions & 3 deletions packages/hydrooj/src/handler/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]))
Expand All @@ -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'
Expand All @@ -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)) {
Lotuses-robot marked this conversation as resolved.
Show resolved Hide resolved
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)) {
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 3 additions & 2 deletions packages/hydrooj/src/handler/record.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { pid } from 'process';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class

Unused import pid.
import {
omit, pick, throttle, uniqBy,
} from 'lodash';
Expand Down Expand Up @@ -58,7 +59,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) {
Expand Down Expand Up @@ -152,7 +153,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;
Expand Down
5 changes: 4 additions & 1 deletion packages/hydrooj/src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ export interface Tdoc<docType = document['TYPE_CONTEST'] | document['TYPE_TRAINI
// For training
description?: string;
dag?: TrainingNode[];

// For codeforces
lockedList?: {};
}

export interface TrainingDoc extends Tdoc {
Expand Down Expand Up @@ -525,7 +528,7 @@ export interface ContestRule<T = any> {
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<T>, tdoc: Tdoc<30>, journal: any[]) => ContestStat & T;
scoreboardHeader: (
this: ContestRule<T>, config: ScoreboardConfig, _: (s: string) => string,
Expand Down
116 changes: 111 additions & 5 deletions packages/hydrooj/src/model/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<number>();
const hackSucc = Counter<number>();
const hackFail = Counter<number>();
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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -890,6 +982,18 @@ export const statusText = (tdoc: Tdoc, tsdoc?: any) => (
? 'Live...'
: 'Done');

export async function getLockedList(domainId: string, tid: ObjectId) {
Lotuses-robot marked this conversation as resolved.
Show resolved Hide resolved
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,
Expand Down Expand Up @@ -922,4 +1026,6 @@ global.Hydro.model.contest = {
isLocked,
isExtended,
statusText,
getLockedList,
updateLockedList,
};
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@ export default connect(mapStateToProps, mapDispatchToProps)(class ScratchpadTool
</ToolbarButton>
)}
<ToolbarButton
disabled={this.props.isPosting || !!this.props.submitWaitSec}
disabled={this.props.isPosting || !!this.props.submitWaitSec ||
(UiContext.tdoc && UiContext.tdoc.lockedList[UiContext.pdoc.docId].includes(UserContext._id))}
className="scratchpad__toolbar__submit"
onClick={() => this.props.postSubmit(this.props)}
data-global-hotkey="f10"
Expand Down
13 changes: 13 additions & 0 deletions packages/ui-default/templates/contest_problemlist.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ <h1 class="section__title">{{ _('Problems') }}</h1>
<a href="{{ url('problem_detail', pid=pid, query={tid:tdoc.docId}) }}">
<b>{{ String.fromCharCode(65+loop.index0) }}</b>&nbsp;&nbsp;{{ pdict[pid].title }}
</a>
{% if tdoc.rule === "cf" %}
<a style="float: right; margin-left: 10px;" href="{{ url('record_main', query={tid:tdoc.docId, pid:pid, status:1}) }}" target="_blank">Hack</a>
<form style="float: right; margin-left: 10px;" action="{{ url('contest_lock_problem', tid=tdoc.docId) }}" method="POST">
<input type="hidden" name="pid" value="{{ pid }}">
<button class="btn--lock" type="submit">
{% if not tdoc.lockedList[pid].includes(handler.user._id) %}
Lock
{% else %}
Locked
{% endif %}
</button>
</form>
{% endif %}
</td>
</tr>
{%- endfor -%}
Expand Down
9 changes: 8 additions & 1 deletion packages/ui-default/templates/problem_submit.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{% extends "layout/basic.html" %}
{% block content %}
{{ set(UiContext, 'pdoc', pdoc) }}
{% if tdoc %}
{{ set(UiContext, 'tdoc', tdoc) }}
{% endif %}
<div class="row">
<div class="medium-9 columns">
<div class="section">
Expand All @@ -26,7 +29,11 @@ <h1 class="section__title">{{ _('Submit to Judge') }}</h1>
</div>
</div>
<div class="row"><div class="columns">
<input type="submit" class="rounded primary button" value="{{ _('Submit') }}">
{% if not tdoc or not tdoc.lockedList[pdoc.docId].includes(handler.user._id) %}
<input type="submit" class="rounded primary button" value="{{ _('Submit') }}">
{% else %}
<input disabled type="submit" class="rounded primary button" value="{{ _('Locked') }}">
{% endif %}
</div></div>
</form>
</div>
Expand Down
12 changes: 9 additions & 3 deletions packages/ui-default/templates/record_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ <h1 class="section__title">{{ _('Information') }}</h1>
<div class="section__body no-padding">
<ol class="menu">
<li class="menu__item">
<a class="menu__link" href="{{ url('problem_hack', pid=pdoc.pid|default(pdoc.docId), rid=rdoc._id) }}">
<span class="icon icon-debug"></span> {{ _('Hack') }}
</a>
{% if rdoc.contest %}
<a class="menu__link" href="{{ url('problem_hack', pid=pdoc.pid|default(pdoc.docId), rid=rdoc._id, query={tid:rdoc.contest}) }}">
<span class="icon icon-debug"></span> {{ _('Hack') }}
</a>
{% else %}
<a class="menu__link" href="{{ url('problem_hack', pid=pdoc.pid|default(pdoc.docId), rid=rdoc._id) }}">
<span class="icon icon-debug"></span> {{ _('Hack') }}
</a>
{% endif %}
</li>
</ol>
</div>
Expand Down