Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions app/(protected)/admin/contests/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,8 @@ export async function finalizeContest(formData: FormData) {
userId: true,
contestProblemId: true,
verdict: true,
passedCount: true,
totalCount: true,
createdAt: true,
},
},
Expand Down Expand Up @@ -274,6 +276,7 @@ export async function finalizeContest(formData: FormData) {
userId,
rank: standing?.rank ?? participantIds.size,
solvedCount: standing?.solvedCount ?? 0,
score: standing?.score ?? 0,
penalty: standing?.penalty ?? 0,
ratingBefore,
ratingAfter: change.newRating,
Expand Down
5 changes: 4 additions & 1 deletion app/(protected)/admin/problems/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export async function runReferenceSolution(slug: string, language?: string): Pro
select: { language: true, code: true },
},
timeLimitMs: true,
testCases: { orderBy: { order: "asc" }, select: { input: true, expectedOutput: true } },
testCases: {
orderBy: { order: "asc" },
select: { input: true, expectedOutput: true },
},
},
});

Expand Down
12 changes: 9 additions & 3 deletions app/contests/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default async function ContestDetailPage({
userId: true,
contestProblemId: true,
verdict: true,
passedCount: true,
totalCount: true,
createdAt: true,
},
},
Expand Down Expand Up @@ -192,22 +194,26 @@ export default async function ContestDetailPage({
<section className="practice-leaderboard" aria-labelledby="contest-standings-title">
<div className="practice-leaderboard-header">
<h2 id="contest-standings-title">Standings</h2>
<span>Solved · Time taken</span>
<span>Points · Solved · Time taken</span>
</div>
<p className="nudge-meta">
Each problem is worth 100 points. Only your best submission per problem counts.
</p>
{standings.length > 0 ? (
<div className="leaderboard-list">
{standings.map((entry) => (
<div className="leaderboard-row" key={entry.userId}>
<span className="leaderboard-rank">#{entry.rank}</span>
<span className="leaderboard-name">{entry.name}</span>
<span className="leaderboard-score">
{entry.solvedCount} · {entry.penalty} min
{entry.score}/{contest.problems.length * 100} · {entry.solvedCount} solved ·{" "}
{entry.penalty} min
</span>
</div>
))}
</div>
) : (
<div className="form-message">No accepted submissions yet.</div>
<div className="form-message">No scored submissions yet.</div>
)}
</section>
</section>
Expand Down
6 changes: 6 additions & 0 deletions components/practice/problem-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ const starterCode: Record<string, string> = {
};

function SubmissionMeta({ submission }: Readonly<{ submission: Submission }>) {
const score =
submission.totalCount > 0
? Math.round((submission.passedCount * 100) / submission.totalCount)
: 0;

return (
<>
<strong className="verdict-label">
Expand All @@ -72,6 +77,7 @@ function SubmissionMeta({ submission }: Readonly<{ submission: Submission }>) {
<span>
{submission.passedCount}/{submission.totalCount} tests
</span>
{submission.totalCount > 0 ? <span>{score}/100 points</span> : null}
<span>{submission.runtimeMs ?? 0}ms</span>
{submission.note ? <span className="submission-note">{submission.note}</span> : null}
</>
Expand Down
63 changes: 46 additions & 17 deletions lib/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,17 @@ export type ContestSubmissionRow = {
userId: string;
contestProblemId: string;
verdict: SubmissionVerdict;
passedCount: number;
totalCount: number;
createdAt: Date;
};

export type StandingRow = {
userId: string;
score: number;
solvedCount: number;
penalty: number;
lastAcAt: Date | null;
lastScoredAt: Date | null;
rank: number;
};

Expand Down Expand Up @@ -192,6 +195,12 @@ function minutesFromStart(startsAt: Date, at: Date) {
return Math.max(0, Math.floor((at.getTime() - startsAt.getTime()) / 60_000));
}

function submissionScore(submission: ContestSubmissionRow) {
return submission.totalCount > 0
? Math.round((submission.passedCount * 100) / submission.totalCount)
: 0;
}

export function computeStandings(
submissions: ContestSubmissionRow[],
startsAt: Date,
Expand All @@ -206,53 +215,73 @@ export function computeStandings(
byUserProblem.set(key, bucket);
}

const stats = new Map<string, { solvedCount: number; penalty: number; lastAcAt: Date | null }>();
const stats = new Map<
string,
{ score: number; solvedCount: number; penalty: number; lastScoredAt: Date | null }
>();

for (const [key, attempts] of Array.from(byUserProblem.entries())) {
const userId = key.split(":")[0]!;
const ordered = [...attempts].sort(
(left, right) => left.createdAt.getTime() - right.createdAt.getTime(),
);
const acceptedIndex = ordered.findIndex(
(attempt) => attempt.verdict === SubmissionVerdict.ACCEPTED,
);
let bestIndex = -1;
let bestScore = 0;
for (let index = 0; index < ordered.length; index += 1) {
const attempt = ordered[index]!;
const score = submissionScore(attempt);
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
}

if (acceptedIndex < 0) {
if (bestIndex < 0) {
continue;
}

const accepted = ordered[acceptedIndex]!;
const bestAttempt = ordered[bestIndex]!;
const wrongBefore = ordered
.slice(0, acceptedIndex)
.slice(0, bestIndex)
.filter((attempt) => attempt.verdict !== SubmissionVerdict.PENDING).length;
const problemPenalty =
minutesFromStart(startTimesByUser.get(userId) ?? startsAt, accepted.createdAt) +
minutesFromStart(startTimesByUser.get(userId) ?? startsAt, bestAttempt.createdAt) +
wrongBefore * CONTEST_WRONG_PENALTY_MINUTES;

const current = stats.get(userId) ?? { solvedCount: 0, penalty: 0, lastAcAt: null };
current.solvedCount += 1;
const current = stats.get(userId) ?? {
score: 0,
solvedCount: 0,
penalty: 0,
lastScoredAt: null,
};
current.score += bestScore;
if (bestAttempt.verdict === SubmissionVerdict.ACCEPTED) {
current.solvedCount += 1;
}
current.penalty += problemPenalty;
current.lastAcAt =
!current.lastAcAt || accepted.createdAt > current.lastAcAt
? accepted.createdAt
: current.lastAcAt;
current.lastScoredAt =
!current.lastScoredAt || bestAttempt.createdAt > current.lastScoredAt
? bestAttempt.createdAt
: current.lastScoredAt;
stats.set(userId, current);
}

const ranked = Array.from(stats.entries())
.map(([userId, row]) => ({ userId, ...row }))
.sort(
(left, right) =>
right.score - left.score ||
right.solvedCount - left.solvedCount ||
left.penalty - right.penalty ||
(left.lastAcAt?.getTime() ?? 0) - (right.lastAcAt?.getTime() ?? 0),
(left.lastScoredAt?.getTime() ?? 0) - (right.lastScoredAt?.getTime() ?? 0),
);

return ranked.map((row, index) => ({
userId: row.userId,
score: row.score,
solvedCount: row.solvedCount,
penalty: row.penalty,
lastAcAt: row.lastAcAt,
lastScoredAt: row.lastScoredAt,
rank: index + 1,
}));
}
Expand Down
35 changes: 15 additions & 20 deletions lib/judge/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export async function judgeSubmission({
}): Promise<JudgeResult> {
let passedCount = 0;
let runtimeMs = 0;
let verdict: SubmissionVerdict = SubmissionVerdict.ACCEPTED;
let failureMessage: string | null = null;

for (const testCase of testCases) {
const result = await executor({ language, code, stdin: testCase.input, timeLimitMs });
Expand All @@ -75,40 +77,33 @@ export async function judgeSubmission({
}

if (result.timedOut || result.signal === "SIGKILL" || result.signal === "SIGTERM") {
return {
verdict: SubmissionVerdict.TLE,
passedCount,
totalCount: testCases.length,
runtimeMs,
};
verdict = SubmissionVerdict.TLE;
continue;
}

if (result.exitCode !== 0) {
return {
verdict: SubmissionVerdict.RUNTIME_ERROR,
passedCount,
totalCount: testCases.length,
runtimeMs,
failureMessage: runtimeFailureMessage(result, testCase.isSample),
};
if (verdict !== SubmissionVerdict.TLE) {
verdict = SubmissionVerdict.RUNTIME_ERROR;
}
failureMessage ??= runtimeFailureMessage(result, testCase.isSample);
continue;
}

if (normalizeOutput(result.stdout) !== normalizeOutput(testCase.expectedOutput)) {
return {
verdict: SubmissionVerdict.WRONG_ANSWER,
passedCount,
totalCount: testCases.length,
runtimeMs,
};
if (verdict === SubmissionVerdict.ACCEPTED) {
verdict = SubmissionVerdict.WRONG_ANSWER;
}
continue;
}

passedCount += 1;
}

return {
verdict: SubmissionVerdict.ACCEPTED,
verdict,
passedCount,
totalCount: testCases.length,
runtimeMs,
failureMessage,
};
}
Loading
Loading