Skip to content

Commit

Permalink
onsite-toolkit: add resolver (#706)
Browse files Browse the repository at this point in the history
Co-authored-by: panda <panda_dtdyy@outlook.com>
  • Loading branch information
undefined-moe and pandadtdyy authored Nov 13, 2024
1 parent d1e79f3 commit f888537
Show file tree
Hide file tree
Showing 7 changed files with 510 additions and 6 deletions.
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

0 comments on commit f888537

Please sign in to comment.