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

onsite-toolkit: add resolver #706

Merged
merged 19 commits into from
Nov 13, 2024
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
280 changes: 280 additions & 0 deletions packages/onsite-toolkit/frontend/resolver.page..tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
/* eslint-disable no-await-in-loop */
import { animated, easings, useSprings } from '@react-spring/web';
import useKey from 'react-use/lib/useKey';
import {
addPage, NamedPage, React, ReactDOM,
} from '@hydrooj/ui-default';
import { ResolverInput } from '../interface';

async function scrollTo(offset) {
const fixedOffset = offset.toFixed();
await new Promise((resolve) => {
const onScroll = function () {
if (window.pageYOffset.toFixed() === fixedOffset) {
window.removeEventListener('scroll', onScroll);
resolve(null);
}
};

window.addEventListener('scroll', onScroll);
onScroll();
window.scrollTo({
top: offset,
behavior: 'smooth',
});
});
}
export interface DisplaySettings {
showAvatar: boolean;
showSchool: boolean;
}

interface Props extends DisplaySettings {
data: ResolverInput;
}

function status(problem) {
if (!problem) return 'untouched';
if (problem.pass) return 'ac';
if (!problem.old && !problem.frozen) return 'untouched';
if (problem.frozen) return 'frozen';
return 'failed';
}

function submissions(problem) {
const st = status(problem);
if (st === 'ac') { return `${problem.old}`; }
if (st === 'frozen') { return `${problem.old}+${problem.frozen}`; }
if (st === 'failed') { return problem.old; }
return String.fromCharCode('A'.charCodeAt(0) + problem.index);
}

export function start(data: ResolverInput, options: DisplaySettings) {
$('title').text(`${data.name} - Resolver`);
$('.header .title').text(`${data.name}`);
const teams = data.teams.map((v) => ({
id: v.id,
rank: 0,
score: 0,
penalty: 0,
ranked: !v.exclude,
total: 0,
problems: data.problems.map((problem, idx) => ({
old: 0,
frozen: 0,
pass: false,
id: problem.id,
index: idx,
})),
}));
const allSubmissions = data.submissions.sort((a, b) => a.time - b.time);
const allAc = allSubmissions.filter((i) => i.verdict === 'AC');
for (const submission of allSubmissions) {
const team = teams.find((v) => v.id === submission.team);
if (!team) continue;
const isFrozen = submission.time > data.frozen;
const problem = team.problems.find((i) => i.id === submission.problem);
if (!problem || problem.pass) continue;
team.total++;
if (isFrozen) problem.frozen += 1;
else {
if (submission.verdict === 'AC') {
problem.pass = true;
team.score += 1;
team.penalty += submission.time + problem.old * 20 * 60;
}
problem.old += 1;
}
}

function MainList(props: Props) {
const [selectedTeam, setTeam] = React.useState('');
const [selectedProblem, setP] = React.useState<string | null>(null);
const [executeIdx, setExecuteIdx] = React.useState(0);
const [, setRenderC] = React.useState(0);

function processRank(source = teams) {
const clone = [...source];
clone.sort((a, b) => b.score - a.score || a.penalty - b.penalty || b.total - a.total);
let rank = 1;
for (const team of clone) {
if (team.ranked) {
team.rank = rank;
rank++;
} else {
team.rank = -1;
}
}
return clone.map((i) => source.indexOf(i));
}

const order = React.useRef(processRank());

const [springs, api] = useSprings(teams.length, (index) => ({
y: order.current.indexOf(index) * 80 - index * 80,
scale: 1,
zIndex: 0,
shadow: 1,
immediate: (key: string) => key === 'y' || key === 'zIndex',
}));

const operations = {
async highlightTeam(teamId: string, scrollIdx: number) {
setP(null);
setTeam(teamId);
await scrollTo(scrollIdx * 80 - window.innerHeight + 241 + 40);
},
async highlightProblem(problemId: string) {
setP(problemId);
},
async revealProblem(teamId: string, problemId: string) {
const team = teams.find((i) => i.id === teamId);
const problem = team?.problems.find((i) => i.id === problemId);
if (!team || !problem) return;
if (allAc.find((s) => s.team === teamId && s.problem === problemId)) {
const sub = allSubmissions.filter((s) => s.team === teamId && s.problem === problemId);
let penalty = 0;
for (const s of sub) {
if (s.verdict !== 'AC') {
penalty += 20 * 60;
problem.old++;
} else {
penalty += s.time;
break;
}
}
team.penalty += penalty;
team.score += 1;
problem.pass = true;
problem.frozen = 0;
} else {
problem.old += problem.frozen;
problem.frozen = 0;
}
setP(null);
},
async updateRank() {
order.current = processRank();
api.start((index) => ({
y: order.current.indexOf(index) * 80 - index * 80,
scale: 1,
zIndex: 0,
shadow: 1,
config: {
easing: easings.steps(5),
},
}));
},
};

const calculated = React.useMemo(() => {
window.scrollTo(0, document.body.scrollHeight);
const clone = JSON.parse(JSON.stringify(teams));
const ops: { name: string, args: any[] }[] = [];
function queueOperations(name: string, ...args: any[]) {
ops.push({ name, args });
}
let orders = processRank(clone);
for (let i = clone.length - 1; i > 0; i--) {
const team = clone[orders[i]];
queueOperations('highlightTeam', team.id, i);
for (const pinfo of data.problems) {
const problem = team.problems.find((idx) => idx.id === pinfo.id);
if (!problem || !problem.frozen || problem.pass) continue;
queueOperations('highlightProblem', pinfo.id);
queueOperations('revealProblem', team.id, pinfo.id);
// scroll to selected line
if (allAc.find((s) => s.team === team.id && s.problem === problem.id)) {
const sub = allSubmissions.filter((s) => s.team === team.id && s.problem === problem.id);
let penalty = 0;
for (const s of sub) {
if (s.verdict !== 'AC') {
penalty += 20 * 60;
problem.old++;
} else {
penalty += s.time;
break;
}
}
team.penalty += penalty;
team.score += 1;
problem.pass = true;
problem.frozen = 0;
queueOperations('updateRank');
const oldOrder = JSON.stringify(orders);
orders = processRank(clone);
if (oldOrder !== JSON.stringify(orders)) {
i++;
break;
}
} else {
problem.old += problem.frozen;
problem.frozen = 0;
}
}
}
return ops;
}, [data]);

useKey('n', async () => {
const op = calculated[executeIdx];
if (!op) return;
setExecuteIdx(executeIdx + 1);
await operations[op.name](...op.args);
setRenderC((i) => i + 1);
}, {}, [executeIdx, calculated]);

return (<>
{springs.map(({
zIndex, y,
}, i) => {
const team = teams[i];
const teamInfo = data.teams.find((idx) => idx.id === team.id);
if (!teams[i]) return <animated.div key={i}>Team {i} not found</animated.div>;
if (!teamInfo) return <animated.div key={i}>Team info for id {team.id} not found</animated.div>;
return <animated.div
key={i}
className="rank-list-item clearfix"
style={{
zIndex,
// boxShadow: shadow.to((s) => `rgba(0, 0, 0, 0.15) 0px ${s}px ${2 * s}px 0px`),
y,
...(selectedTeam === team.id ? {
backgroundColor: '#406b82',
} : {
background: 'transparent',
}),
}}
children={<>
<div className="rank">{team.rank === -1 ? '*' : team.rank}</div>
{props.showAvatar && <img className="avatar" src={`${teamInfo?.avatar}`} />}
<div className="content">
<div className="name">
{props.showSchool ? `${teamInfo.institution} - ` : ''}{teamInfo.name}
</div>
<div className="problems">
{data.problems.map((v) => {
const uncover = team.id === selectedTeam && selectedProblem === v.id;
const problemStatus = team.problems.find((idx) => idx.id === v.id);
return <span key={v.id} className={`${status(problemStatus)} ${uncover ? 'uncover' : ''} item`}>
{submissions(problemStatus)}
</span>;
})}
</div>
</div>
<div className="solved">{team.score}</div>
<div className="penalty">{Math.floor(team.penalty / 60)}</div>
</>}
/>;
})}
</>);
}
ReactDOM.createRoot(document.getElementById('rank-list')!).render(<MainList {...options} data={data} />);
}

addPage(new NamedPage(['resolver'], () => {
start(UiContext.payload, {
showAvatar: true,
showSchool: true,
});
}));
45 changes: 44 additions & 1 deletion packages/onsite-toolkit/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
Context, db, ForbiddenError, UserModel,
avatar, ContestModel, Context, db, ForbiddenError,
ObjectId, PERM, STATUS, Time, UserModel,
} from 'hydrooj';
import { ResolverInput } from './interface';

interface IpLoginInfo {
_id: string;
Expand Down Expand Up @@ -33,4 +35,45 @@ export function apply(ctx: Context) {
that.session.user = that.user;
}
});

ctx.inject(['scoreboard'], ({ scoreboard }) => {
scoreboard.addView('resolver-tiny', 'Resolver(Tiny)', { tdoc: 'tdoc' }, {
async display({ tdoc }) {
if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD);
const teams = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray();
const udict = await UserModel.getList(tdoc.domainId, teams.map((i) => i.uid));
const teamIds: Record<number, number> = {};
for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i;
const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second);
const pid = (i: number) => String.fromCharCode(65 + i);
const unknownSchool = this.translate('Unknown School');
const submissions = teams.flatMap((i) => {
if (!i.journal) return [];
return i.journal.filter((s) => tdoc.pids.includes(s.pid)).map((s) => ({ ...s, uid: i.uid }));
});
this.response.body = {
payload: {
name: tdoc.title,
duration: Math.floor((new Date(tdoc.endAt).getTime() - new Date(tdoc.beginAt).getTime()) / 1000),
frozen: Math.floor((new Date(tdoc.endAt).getTime() - new Date(tdoc.lockAt).getTime()) / 1000),
problems: tdoc.pids.map((i, n) => ({ name: pid(n), id: i.toString() })),
teams: teams.map((t) => ({
id: t.uid.toString(),
name: udict[t.uid].uname,
avatar: avatar(udict[t.uid].avatar),
institution: udict[t.uid].school || unknownSchool,
})),
submissions: submissions.map((i) => ({
team: i.uid.toString(),
problem: i.pid.toString(),
verdict: i.status === STATUS.STATUS_ACCEPTED ? 'AC' : 'RJ',
time: time(i.rid),
})),
} as ResolverInput,
};
this.response.template = 'resolver.html';
},
supportedRules: ['acm'],
});
});
}
33 changes: 33 additions & 0 deletions packages/onsite-toolkit/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
type Verdict = 'RJ' | 'AC' | 'NA';
export interface ProblemInfo {
color?: string; // hex color
id: string;
name: string; // A, B, C, ...
}
export interface TeamInfo {
id: string;
name: string;
avatar?: string;
institution?: string;
exclude?: boolean; // false by default
}
export interface InstitiutionInfo {
id: string;
name: string;
avatar?: string;
}
export interface SubmissionInfo {
team: string;
problem: string;
verdict: Verdict;
time: number; // in seconds
}
export interface ResolverInput {
name: string;
duration: number; // in seconds
frozen: number; // in seconds
problems: ProblemInfo[];
submissions: SubmissionInfo[];
teams: TeamInfo[];
institutions: Record<string, InstitiutionInfo>;
}
6 changes: 5 additions & 1 deletion packages/onsite-toolkit/package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"name": "@hydrooj/onsite-toolkit",
"version": "0.0.1"
"version": "0.0.1",
"dependencies": {
"@react-spring/web": "^9.7.3",
"react-use": "^17.4.2"
}
}
Loading
Loading