From da415c4a7cf40423875a895a4b95bbde83e82403 Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Tue, 27 Jan 2026 12:13:17 +0800 Subject: [PATCH 1/4] feat(contest): add random-set mode scoreboard Only Support IOI2013. --- src/handlers/contests/scoreboard.py | 57 ++++++- src/handlers/pro.py | 15 ++ src/services/contests.py | 101 +++++++++++- src/services/judge.py | 2 + src/static/templ/contests/scoreboard.html | 180 +++++++++++++++++++--- 5 files changed, 325 insertions(+), 30 deletions(-) diff --git a/src/handlers/contests/scoreboard.py b/src/handlers/contests/scoreboard.py index 9fee34394..f847b3177 100644 --- a/src/handlers/contests/scoreboard.py +++ b/src/handlers/contests/scoreboard.py @@ -7,7 +7,7 @@ import config from handlers.base import RequestHandler, UnifiedWebSocketHandler, reqenv -from services.contests import ContestService, ProblemScoreType, UserStatus +from services.contests import ContestService, ProblemScoreType, UserStatus, ContestMode from services.user import UserService @@ -104,6 +104,58 @@ def _encoder(self, obj): return obj + async def random_set_scoreboard(self): + if self.contest.is_public_scoreboard: + acct_list = [acct_id for acct_id, v in self.contest.user_list.items() if v['status'] == UserStatus.APPROVED] + else: + acct_list = [self.acct.acct_id] + + scoreboard_key = f'contest_{self.contest.contest_id}_randomset_scoreboard' + pro_sets_len = len(self.contest.pro_sets) + keys = [f'{acct_id}_{pro_order}' for acct_id in acct_list for pro_order in range(pro_sets_len)] + score_values = await self.rs.hmget(scoreboard_key, keys) + + start_time = self.contest.contest_start + all_scores = [] + for acct_cnt, acct_id in enumerate(acct_list): + _, acct = await UserService.inst.info_acct(acct_id) + assert acct + prolist = self.contest.get_prolist_from_acct_by_ip(acct) + if prolist is None: + continue + + total_score = Decimal('0') + scores = {} + for pro_order, pro_id in enumerate(prolist): + idx = acct_cnt * pro_sets_len + pro_order + assert 0 <= idx < len(score_values) + + best_record = score_values[idx] + if best_record is None: + continue + + best_record = unpackb(best_record) + best_record['timestamp'] = datetime.datetime.fromtimestamp(best_record['timestamp']) + best_record['score'] = Decimal(best_record['score']) + scores[pro_order] = { + 'pro_id': pro_id, + 'chal_id': best_record['chal_id'], + 'timestamp': best_record['timestamp'].astimezone(config.TIMEZONE) - start_time, + 'score': best_record['score'], + 'state': best_record['state'], + 'fail_cnt': best_record['fail_cnt'] + } + total_score += best_record['score'] + + all_scores.append({ + 'acct_id': acct_id, + 'name': acct.name, + 'scores': scores, + 'total_score': total_score + }) + + self.error(('S', all_scores), encoder=_JsonDatetimeEncoder) + @reqenv async def get(self): await self.render('contests/scoreboard', contest=self.contest) @@ -115,6 +167,9 @@ async def post(self): elif not self.contest.is_public_scoreboard and not self.contest.is_member(self.acct): return self.error(('Eacces', 'Permission denied')) + if self.contest.contest_mode == ContestMode.RANDOM_SET: + return await self.random_set_scoreboard() + has_end_time = True start_time = self.contest.contest_start try: diff --git a/src/handlers/pro.py b/src/handlers/pro.py index 43ec32954..f77ffcf89 100644 --- a/src/handlers/pro.py +++ b/src/handlers/pro.py @@ -8,6 +8,7 @@ from services.pro import ProClassService, ProClassConst, ProConst, ProService, Problem from services.rate import RateService from services.user import UserService, UserConst +from services.contests import ContestMode PERMISSION_DENIED_ERROR = ("Eacces", "Permission denied") @@ -175,6 +176,13 @@ async def get(self, pro_id: int, path: str): if not self.contest.is_admin(self.acct) and not self.contest.is_running(): return self.error(PERMISSION_DENIED_ERROR) + if self.contest.contest_mode == ContestMode.RANDOM_SET: + contest_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + if contest_prolist is None: + return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) + if pro_id not in contest_prolist: + return self.error(("Enoext", "Problem not in your problem set")) + allow_statuses = ProConst.PRO_STATUS_CONTEST_USER else: if self.acct.is_kernel(): @@ -239,6 +247,13 @@ async def get(self, pro_id): if not self.contest.is_admin(self.acct) and not self.contest.is_running(): return self.error(PERMISSION_DENIED_ERROR) + if self.contest.contest_mode == ContestMode.RANDOM_SET: + contest_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + if contest_prolist is None: + return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) + if pro_id not in contest_prolist: + return self.error(PERMISSION_DENIED_ERROR) + allow_statuses = ProConst.PRO_STATUS_CONTEST_USER else: diff --git a/src/services/contests.py b/src/services/contests.py index 43e36da91..ce995f0d5 100644 --- a/src/services/contests.py +++ b/src/services/contests.py @@ -1,15 +1,19 @@ import datetime import enum from dataclasses import dataclass, field +import random import pickle +from ipaddress import IPv4Address, AddressValueError +from decimal import Decimal import asyncpg -from ipaddress import IPv4Address -import random +import tornado.log +from msgpack import packb, unpackb -from services.chal import Compiler -from services.user import Account +from services.chal import ChalService, Compiler, ChalConst +from services.user import Account, UserService +logger = tornado.log.app_log class RegMode(enum.IntEnum): INVITED = 0 @@ -113,6 +117,21 @@ def member_is_status(self, acct: Account | int, status: UserStatus) -> bool: return self.user_list[acct_id]['status'] == status + def get_prolist_from_acct_by_ip(self, acct: Account) -> list[int] | None: + if self.contest_mode != ContestMode.RANDOM_SET: + return None + + try: + ip = IPv4Address(acct.lastip) + except AddressValueError: + return None + + try: + return self.ip_pro_list[ip] + except KeyError: + logger.warning(f'TODO: Assign problem set for out of range IP not implemented. Contest {self.contest_id} IP range {self.start_ip} - {self.end_ip}, but acct {acct.acct_id} IP is {ip}.') + return None + class ContestService: def __init__(self, db, rs): self.db = db @@ -181,9 +200,9 @@ async def get_contest(self, contest_id: int): if contest.contest_mode == ContestMode.RANDOM_SET: result = await con.fetch( ''' - SELECT ip, contest_ip_joints.pro_id - FROM contest_ip_joints INNER JOIN contest_problem_joints - ON contest_ip_joints.contest_id = contest_problem_joints.contest_id + SELECT ip, contest_ip_joints.pro_id + FROM contest_ip_joints INNER JOIN contest_problem_joints + ON contest_ip_joints.contest_id = contest_problem_joints.contest_id AND contest_ip_joints.pro_id = contest_problem_joints.pro_id WHERE contest_ip_joints.contest_id = $1 ORDER BY contest_ip_joints.ip, contest_problem_joints."order" ASC; ''', @@ -500,6 +519,74 @@ async def reorder_pro_set(self, contest: Contest, new_idxs: list[int]): ) return None, None + def _encoder(self, obj): + if isinstance(obj, datetime.datetime): + return obj.timestamp() + + elif isinstance(obj, Decimal): + return str(obj) + + return obj + + async def update_randomset_scoreboard(self, chal_id: int): + err, chal = await ChalService.inst.get_chal(chal_id) + if err: + return + assert chal + + err, contest = await self.get_contest(chal.contest_id) + if err: + return + assert contest + + if contest.contest_mode != ContestMode.RANDOM_SET: + return + + if contest.is_admin(acct_id=chal.acct_id): + return + + err, acct = await UserService.inst.info_acct(chal.acct_id) + if err: + return + assert acct + + + prolist = contest.get_prolist_from_acct_by_ip(acct) + if prolist is None: + return + + try: + pro_order = prolist.index(chal.pro_id) + except IndexError: + logger.error(f'Contest {contest.contest_id} randomset problem list for acct {acct.acct_id} does not contain problem {chal.pro_id}.') + return + + err, total_result = await ChalService.inst.get_total_result(chal.chal_id) + if err: + return + assert total_result + + best_record = await self.rs.hget(f'contest_{contest.contest_id}_randomset_scoreboard', f'{acct.acct_id}_{pro_order}') + fail_cnt = total_result.state != ChalConst.STATE_AC + if best_record is not None: + best_record = unpackb(best_record) + fail_cnt = best_record['fail_cnt'] + + if best_record is None or total_result.rate > Decimal(best_record['score']): + # NOTE: IOI2013 + best_record = { + 'chal_id': chal.chal_id, + 'timestamp': chal.timestamp, + 'state': total_result.state, + 'score': total_result.rate, + } + if best_record['state'] != ChalConst.STATE_AC: + fail_cnt += 1 + + best_record['fail_cnt'] = fail_cnt + + await self.rs.hset(f'contest_{contest.contest_id}_randomset_scoreboard', f'{acct.acct_id}_{pro_order}', packb(best_record, default=self._encoder)) + async def get_ioi2013_scores(self, contest_id: int, pro_id: int, before_time: datetime.datetime) -> dict: _, contest = await self.get_contest(contest_id) res = await self.db.fetch( diff --git a/src/services/judge.py b/src/services/judge.py index 4027045ed..31e7f6767 100644 --- a/src/services/judge.py +++ b/src/services/judge.py @@ -186,6 +186,8 @@ async def response_handle(self, ret: str): if contest_id != 0: await self.rs.publish('contestnewchalsub', contest_id) await self.rs.hdel(f'contest_{contest_id}_scores', str(pro_id)) + from services.contests import ContestService + await ContestService.inst.update_randomset_scoreboard(chal_id) # NOTE: Recalculate problem rate await RateService.inst.refresh_pro_ac_rate(pro_id, contest_id) diff --git a/src/static/templ/contests/scoreboard.html b/src/static/templ/contests/scoreboard.html index 62935497f..ca4e7ad1f 100644 --- a/src/static/templ/contests/scoreboard.html +++ b/src/static/templ/contests/scoreboard.html @@ -1,4 +1,5 @@ {% import datetime %} +{% from services.chal import ChalConst %} {% from services.contests import ContestMode %} {% from utils.htmlgen import pro_idx_to_pro_alphabet %} @@ -103,9 +104,14 @@ } /** + * @function compare_timestamp + * @description Compare two timestamp strings in "MM:SS" format * @argument p { String } * @argument q { String } * @returns { number } + * Compare two timestamp strings in "MM:SS" format + * p > q => return true + * p <= q => return false */ function compare_timestamp(p, q) { let [p_min, p_sec] = p.split(":"); @@ -117,8 +123,8 @@ q_min = isNaN(q_min) ? 0 : q_min; q_sec = isNaN(q_min) ? 0 : q_sec; - if (p_min == q_min) return p_sec > q_sec; - return p_min > q_min; + if (p_min != q_min) return p_min > q_min; + return p_sec > q_sec; } // from https://www.explainthis.io/zh-hant/swe/throttle @@ -220,30 +226,150 @@ * @type { Array } */ let scores = res.data; - scores.sort((p, q) => { - if (q.total_score == p.total_score) { - let p_max_timestamp = ''; - let q_max_timestamp = ''; + {% if contest.contest_mode is ContestMode.IOI %} + render_ioi_style_scoreboard(scores); + {% elif contest.contest_mode is ContestMode.RANDOM_SET %} + render_randomset_style_scoreboard(scores); + {% end %} + }); + } + + {% if contest.contest_mode is ContestMode.RANDOM_SET %} - for (const [_, score] of Object.entries(p.scores)) { - if (compare_timestamp(score.timestamp, p_max_timestamp)) p_max_timestamp = score.timestamp; + /** + * @argument scores_data { Array } + */ + function render_randomset_style_scoreboard(scores_data) { + /** + * A map of problem_id to first kill acct_id + * @type { Map } + */ + let first_kill_map = new Map(); + scores_data.forEach(scores => { + scores.ac_count = Object.values(scores.scores).filter(score => score.state == {{ ChalConst.STATE_AC }}).length; + scores.fail_count = Object.values(scores.scores).reduce((acc, score) => acc + score.fail_count, 0); + scores.max_timestamp = Object.values(scores.scores).reduce((max, score) => + compare_timestamp(score.timestamp, max) ? score.timestamp : max, ''); + scores.latest_score_changed_timestamp = Object.values(scores.scores) + .filter(score => score.score !== 0) + .reduce((latest, score) => + compare_timestamp(score.timestamp, latest) ? score.timestamp : latest, ''); + + Object.entries(scores.scores) + .filter(([_, score]) => score.state === {{ ChalConst.STATE_AC }}) + .forEach(([pro_id, score]) => { + const shouldUpdate = !first_kill_map.has(pro_id) || + compare_timestamp(score.timestamp, first_kill_map.get(pro_id).timestamp); + + if (shouldUpdate) { + first_kill_map.set(pro_id, { + acct_id: scores.acct_id, + timestamp: score.timestamp + }); } + }); + }); + scores_data.forEach(scores => { + scores.first_kill_count = Object.entries(scores.scores) + .filter(([_, score]) => score.state === {{ ChalConst.STATE_AC }}) + .filter(([pro_id]) => first_kill_map.get(pro_id).acct_id === scores.acct_id) + .length; + }); + + scores_data.sort((p, q) => { + const p_not_have_challenges = Object.keys(p.scores).length === 0; + const q_not_have_challenges = Object.keys(q.scores).length === 0; + if (p_not_have_challenges || q_not_have_challenges) return p_not_have_challenges - q_not_have_challenges; + + if (q.total_score != p.total_score) return q.total_score - p.total_score; + if (q.ac_count != p.ac_count) return q.ac_count - p.ac_count; + if (q.first_kill_count != p.first_kill_count) return q.first_kill_count - p.first_kill_count; + if (q.fail_count != p.fail_count) return p.fail_count - q.fail_count; + return compare_timestamp(p.latest_score_changed_timestamp, q.latest_score_changed_timestamp) ? 1 : -1; + }); + + const pro_list = {{ [*range(len(contest.pro_sets))] }}; + let each_ranks_html = ''; + let cur_rank = 0; + let cur_total_score = 0; + for (const [_, scores] of scores_data.entries()) { + if (scores.total_score != cur_total_score) { + cur_total_score = scores.total_score; + cur_rank += 1; + } - for (const [_, score] of Object.entries(q.scores)) { - if (compare_timestamp(score.timestamp, q_max_timestamp)) q_max_timestamp = score.timestamp; + let each_problems_score_html = ''; + let max_timestamp = scores.max_timestamp; + for (const pro_id of pro_list) { + if (!(pro_id in scores.scores)) { + each_problems_score_html += ` + +

-

+ + `; + } else { + let score = scores.scores[pro_id]; + let score_idx = parseInt(score.score / 10); + if (score_idx > 10) score_idx = 10; + if (score_idx < 0) score_idx = 0; + if (score.state == {{ ChalConst.STATE_AC }}) { + score_idx = 10; } + let score_color_class_html = score_color_class_map[score_idx]; - return compare_timestamp(p_max_timestamp, q_max_timestamp) ? 1 : -1; + each_problems_score_html += ` + +

${score.score}

+

${score.timestamp}

+ + `; } + } - return q.total_score - p.total_score; - }); + each_ranks_html += ` + + ${cur_rank} + ${scores.name} + ${each_problems_score_html} + +

${scores.total_score}

+

${max_timestamp}

+ + + `; + } - {% if contest.contest_mode is ContestMode.IOI %} - render_ioi_style_scoreboard(scores); - {% end %} - }); + let scoreboard_html = ` + + + + + + {% if contest.is_admin(user) or not contest.is_member(user) %} + {% for i in range(len(contest.pro_sets)) %} + + {% end %} + {% else %} + {% for idx, pro_id in enumerate(contest.get_prolist_from_acct_by_ip(user)) %} + + {% end %} + {% end %} + + + + + ${each_ranks_html} + +
RankUsername + {{ pro_idx_to_pro_alphabet(i) }} + + {{ pro_idx_to_pro_alphabet(idx) }} + Total
+ `; + + $("#scoreboard").html(scoreboard_html); } + {% end %} {% if contest.contest_mode is ContestMode.IOI %} @@ -251,6 +377,20 @@ * @argument scores_data { Array } */ function render_ioi_style_scoreboard(scores_data) { + scores_data.forEach(scores => { + scores.max_timestamp = Object.values(scores.scores).reduce((max, score) => + compare_timestamp(score.timestamp, max) ? score.timestamp : max, ''); + }); + + scores_data.sort((p, q) => { + const p_not_have_challenges = Object.keys(p.scores).length === 0; + const q_not_have_challenges = Object.keys(q.scores).length === 0; + if (p_not_have_challenges || q_not_have_challenges) return p_not_have_challenges - q_not_have_challenges; + + if (q.total_score != p.total_score) return q.total_score - p.total_score; + return compare_timestamp(p.max_timestamp, q.max_timestamp) ? 1 : -1; + }); + let pro_list = {{ [pro_id for pro_id in contest.pro_list.keys()] }}; let each_ranks_html = ''; let cur_rank = 0; @@ -262,7 +402,7 @@ } let each_problems_score_html = ''; - let max_timestamp = '00:00'; + let max_timestamp = scores.max_timestamp; for (const pro_id of pro_list) { if (!(pro_id in scores.scores)) { each_problems_score_html += ` @@ -283,10 +423,6 @@

${score.timestamp}

`; - - if (compare_timestamp(score.timestamp, max_timestamp)) { - max_timestamp = score.timestamp; - } } } From 283910a86acea9cac89b16d53840f0f6c867b012 Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Tue, 27 Jan 2026 12:15:19 +0800 Subject: [PATCH 2/4] feat(contest): add random-set mode proset --- src/handlers/contests/proset.py | 61 +++++++++++++++++++++++++-- src/static/templ/contests/proset.html | 3 +- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/handlers/contests/proset.py b/src/handlers/contests/proset.py index 4da9a2cfc..25ccce301 100644 --- a/src/handlers/contests/proset.py +++ b/src/handlers/contests/proset.py @@ -1,21 +1,71 @@ +from decimal import Decimal + +from msgpack import unpackb + from handlers.base import reqenv, RequestHandler from services.pro import ProConst, ProService from services.rate import RateService +from services.contests import ContestMode +PERMISSION_DENIED_ERROR = ('Eacces', 'Permission denied') class ContestProsetHandler(RequestHandler): + async def randomset_proset(self, pageoff: int): + if not self.contest.is_member(self.acct): + return self.error(PERMISSION_DENIED_ERROR) + + scoreboard_key = f'contest_{self.contest.contest_id}_randomset_scoreboard' + randomset_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + if randomset_prolist is None: + return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) + randomset_prolist_set = set(randomset_prolist) + + prolist_order = {pro_id: idx for idx, pro_id in enumerate(randomset_prolist)} + _, prolist = await ProService.inst.list_pro(ProConst.PRO_STATUS_CONTEST_USER) + prolist = sorted(filter(lambda pro: pro.pro_id in randomset_prolist_set, prolist), + key=lambda pro: prolist_order[pro.pro_id]) + + keys = [f'{self.acct.acct_id}_{pro_order}' for pro_order in range(len(prolist))] + score_values = await self.rs.hmget(scoreboard_key, keys) + + score_map: dict[int, dict] = {} + for pro_order, pro in enumerate(prolist): + score_map[pro.pro_id] = {'score': Decimal('0'), 'state': None} + assert 0 <= pro_order < len(score_values) + + best_record = score_values[pro_order] + if best_record is not None: + best_record = unpackb(best_record) + score_map[pro.pro_id]['score'] += Decimal(best_record['score']) + score_map[pro.pro_id]['state'] = best_record['state'] + + pro_total_cnt = len(prolist) + prolist = prolist[pageoff: pageoff + 40] + + pro_idx_map = {pro_id: idx for idx, pro_id in enumerate(randomset_prolist)} + await self.render('contests/proset', contest=self.contest, show_ac_ratio=False, + prolist=prolist, pro_idx_map=pro_idx_map, pro_total_cnt=pro_total_cnt, score_map=score_map, pageoff=pageoff) + + @reqenv async def get(self): pageoff = int(self.get_argument('pageoff', default=0)) show_ac_ratio = False + + if not self.contest.is_member(self.acct): + return self.error(PERMISSION_DENIED_ERROR) + if not self.contest.is_start() and not self.contest.is_admin(self.acct): - return self.error(('Eacces', 'Permission denied')) + return self.error(PERMISSION_DENIED_ERROR) elif self.contest.is_running() and not self.contest.is_member(self.acct): - return self.error(('Eacces', 'Permission denied')) + return self.error(PERMISSION_DENIED_ERROR) else: + if self.contest.contest_mode == ContestMode.RANDOM_SET and not self.contest.is_admin(self.acct): + return await self.randomset_proset(pageoff) + _, acct_rates = await RateService.inst.map_rate_acct(self.acct, contest_id=self.contest.contest_id) _, prolist = await ProService.inst.list_pro(ProConst.PRO_STATUS_CONTEST_USER) @@ -37,8 +87,13 @@ async def get(self): _, rate = await RateService.inst.get_pro_ac_rate(pro.pro_id, contest_id=self.contest.contest_id) score_map[pro.pro_id]['rate_data'] = rate + if self.contest.contest_mode == ContestMode.RANDOM_SET: + pro_idx_map = {pro_id: idx for idx, pro_set in enumerate(self.contest.pro_sets) for pro_id in pro_set} + else: + pro_idx_map = {pro_id: idx for idx, pro_id in enumerate(self.contest.pro_list.keys())} + pro_total_cnt = len(prolist) prolist = prolist[pageoff: pageoff + 40] await self.render('contests/proset', contest=self.contest, show_ac_ratio=show_ac_ratio, - prolist=prolist, pro_total_cnt=pro_total_cnt, score_map=score_map, pageoff=pageoff) + prolist=prolist, pro_idx_map=pro_idx_map, pro_total_cnt=pro_total_cnt, score_map=score_map, pageoff=pageoff) diff --git a/src/static/templ/contests/proset.html b/src/static/templ/contests/proset.html index 6dd4b8318..28d7a1aa5 100644 --- a/src/static/templ/contests/proset.html +++ b/src/static/templ/contests/proset.html @@ -73,9 +73,10 @@ - {% for idx, pro in enumerate(prolist) %} + {% for pro in prolist %} {% set pro_id = pro.pro_id %} + {% set idx = pro_idx_map[pro_id] %} {{ pro_id }} {% if score_map[pro_id]['state'] is None %} Todo From bc55409c03354ce404f9ce076d23fb8c93aea23a Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Tue, 27 Jan 2026 12:47:33 +0800 Subject: [PATCH 3/4] fix(contest): reported by copilot --- src/handlers/contests/proset.py | 2 +- src/handlers/contests/scoreboard.py | 11 +++++-- src/handlers/pro.py | 4 +-- src/services/contests.py | 39 +++++++++++++++-------- src/static/templ/contests/scoreboard.html | 15 ++++----- 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/handlers/contests/proset.py b/src/handlers/contests/proset.py index 25ccce301..662af9fd7 100644 --- a/src/handlers/contests/proset.py +++ b/src/handlers/contests/proset.py @@ -15,7 +15,7 @@ async def randomset_proset(self, pageoff: int): return self.error(PERMISSION_DENIED_ERROR) scoreboard_key = f'contest_{self.contest.contest_id}_randomset_scoreboard' - randomset_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + randomset_prolist = self.contest.get_randomset_prolist_from_acct_by_ip(self.acct) if randomset_prolist is None: return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) randomset_prolist_set = set(randomset_prolist) diff --git a/src/handlers/contests/scoreboard.py b/src/handlers/contests/scoreboard.py index f847b3177..3ab278f63 100644 --- a/src/handlers/contests/scoreboard.py +++ b/src/handlers/contests/scoreboard.py @@ -105,6 +105,11 @@ def _encoder(self, obj): return obj async def random_set_scoreboard(self): + contest = self.contest + acct = self.acct + if contest.is_member(acct) and not contest.is_admin(acct) and not contest.is_randomset_pro_allocated(acct): + return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) + if self.contest.is_public_scoreboard: acct_list = [acct_id for acct_id, v in self.contest.user_list.items() if v['status'] == UserStatus.APPROVED] else: @@ -120,7 +125,7 @@ async def random_set_scoreboard(self): for acct_cnt, acct_id in enumerate(acct_list): _, acct = await UserService.inst.info_acct(acct_id) assert acct - prolist = self.contest.get_prolist_from_acct_by_ip(acct) + prolist = self.contest.get_randomset_prolist_from_acct_by_ip(acct) if prolist is None: continue @@ -143,7 +148,7 @@ async def random_set_scoreboard(self): 'timestamp': best_record['timestamp'].astimezone(config.TIMEZONE) - start_time, 'score': best_record['score'], 'state': best_record['state'], - 'fail_cnt': best_record['fail_cnt'] + 'fail_count': best_record['fail_count'] } total_score += best_record['score'] @@ -241,7 +246,7 @@ async def post(self): 'chal_id': p['chal_id'], 'timestamp': p['timestamp'].astimezone(config.TIMEZONE) - start_time, 'score': p['score'], - 'fail_cnt': p['fail_cnt'] + 'fail_count': p['fail_count'] } total_score += p['score'] diff --git a/src/handlers/pro.py b/src/handlers/pro.py index f77ffcf89..a22fd65e4 100644 --- a/src/handlers/pro.py +++ b/src/handlers/pro.py @@ -177,7 +177,7 @@ async def get(self, pro_id: int, path: str): return self.error(PERMISSION_DENIED_ERROR) if self.contest.contest_mode == ContestMode.RANDOM_SET: - contest_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + contest_prolist = self.contest.get_randomset_prolist_from_acct_by_ip(self.acct) if contest_prolist is None: return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) if pro_id not in contest_prolist: @@ -248,7 +248,7 @@ async def get(self, pro_id): return self.error(PERMISSION_DENIED_ERROR) if self.contest.contest_mode == ContestMode.RANDOM_SET: - contest_prolist = self.contest.get_prolist_from_acct_by_ip(self.acct) + contest_prolist = self.contest.get_randomset_prolist_from_acct_by_ip(self.acct) if contest_prolist is None: return self.error(('Etodo', 'TODO: Assign problem set for out of range IP not implemented. Call Yushiuan9499.')) if pro_id not in contest_prolist: diff --git a/src/services/contests.py b/src/services/contests.py index ce995f0d5..7d4456841 100644 --- a/src/services/contests.py +++ b/src/services/contests.py @@ -117,7 +117,7 @@ def member_is_status(self, acct: Account | int, status: UserStatus) -> bool: return self.user_list[acct_id]['status'] == status - def get_prolist_from_acct_by_ip(self, acct: Account) -> list[int] | None: + def get_randomset_prolist_from_acct_by_ip(self, acct: Account) -> list[int] | None: if self.contest_mode != ContestMode.RANDOM_SET: return None @@ -132,6 +132,21 @@ def get_prolist_from_acct_by_ip(self, acct: Account) -> list[int] | None: logger.warning(f'TODO: Assign problem set for out of range IP not implemented. Contest {self.contest_id} IP range {self.start_ip} - {self.end_ip}, but acct {acct.acct_id} IP is {ip}.') return None + def is_randomset_pro_allocated(self, acct: Account) -> bool: + if self.contest_mode != ContestMode.RANDOM_SET: + return False + + if self.is_admin(acct): + logger.warning(f'Should not call is_randomset_pro_allocated for admin acct {acct.acct_id} in contest {self.contest_id}.') + return False + + try: + ip = IPv4Address(acct.lastip) + except AddressValueError: + return False + + return ip in self.ip_pro_list + class ContestService: def __init__(self, db, rs): self.db = db @@ -550,14 +565,13 @@ async def update_randomset_scoreboard(self, chal_id: int): return assert acct - prolist = contest.get_prolist_from_acct_by_ip(acct) if prolist is None: return try: pro_order = prolist.index(chal.pro_id) - except IndexError: + except ValueError: logger.error(f'Contest {contest.contest_id} randomset problem list for acct {acct.acct_id} does not contain problem {chal.pro_id}.') return @@ -567,23 +581,20 @@ async def update_randomset_scoreboard(self, chal_id: int): assert total_result best_record = await self.rs.hget(f'contest_{contest.contest_id}_randomset_scoreboard', f'{acct.acct_id}_{pro_order}') - fail_cnt = total_result.state != ChalConst.STATE_AC + fail_count = 1 if total_result.state != ChalConst.STATE_AC else 0 if best_record is not None: best_record = unpackb(best_record) - fail_cnt = best_record['fail_cnt'] + fail_count += best_record['fail_count'] + # NOTE: IOI2013 if best_record is None or total_result.rate > Decimal(best_record['score']): - # NOTE: IOI2013 best_record = { 'chal_id': chal.chal_id, 'timestamp': chal.timestamp, 'state': total_result.state, 'score': total_result.rate, + 'fail_count': fail_count, } - if best_record['state'] != ChalConst.STATE_AC: - fail_cnt += 1 - - best_record['fail_cnt'] = fail_cnt await self.rs.hset(f'contest_{contest.contest_id}_randomset_scoreboard', f'{acct.acct_id}_{pro_order}', packb(best_record, default=self._encoder)) @@ -641,9 +652,9 @@ async def get_ioi2013_scores(self, contest_id: int, pro_id: int, before_time: da 'chal_id': chal_id, 'score': score, 'timestamp': timestamp, - 'fail_cnt': fail_cnt + 'fail_count': fail_count } - for acct_id, chal_id, score, timestamp, fail_cnt in res + for acct_id, chal_id, score, timestamp, fail_count in res } return scores @@ -736,9 +747,9 @@ async def get_ioi2017_scores(self, contest_id: int, pro_id: int, before_time: da 'chal_id': chal_id, 'score': score, 'timestamp': timestamp, - 'fail_cnt': fail_cnt + 'fail_count': fail_count } - for acct_id, chal_id, score, timestamp, fail_cnt in res + for acct_id, chal_id, score, timestamp, fail_count in res } return scores diff --git a/src/static/templ/contests/scoreboard.html b/src/static/templ/contests/scoreboard.html index ca4e7ad1f..b206d6519 100644 --- a/src/static/templ/contests/scoreboard.html +++ b/src/static/templ/contests/scoreboard.html @@ -106,12 +106,11 @@ /** * @function compare_timestamp * @description Compare two timestamp strings in "MM:SS" format + * @description p > q => return true + * @description p <= q => return false * @argument p { String } * @argument q { String } * @returns { number } - * Compare two timestamp strings in "MM:SS" format - * p > q => return true - * p <= q => return false */ function compare_timestamp(p, q) { let [p_min, p_sec] = p.split(":"); @@ -121,7 +120,7 @@ q_min = parseInt(q_min); q_sec = parseInt(q_sec); q_min = isNaN(q_min) ? 0 : q_min; - q_sec = isNaN(q_min) ? 0 : q_sec; + q_sec = isNaN(q_sec) ? 0 : q_sec; if (p_min != q_min) return p_min > q_min; return p_sec > q_sec; @@ -259,7 +258,7 @@ .filter(([_, score]) => score.state === {{ ChalConst.STATE_AC }}) .forEach(([pro_id, score]) => { const shouldUpdate = !first_kill_map.has(pro_id) || - compare_timestamp(score.timestamp, first_kill_map.get(pro_id).timestamp); + compare_timestamp(first_kill_map.get(pro_id).timestamp, score.timestamp); if (shouldUpdate) { first_kill_map.set(pro_id, { @@ -300,15 +299,15 @@ let each_problems_score_html = ''; let max_timestamp = scores.max_timestamp; - for (const pro_id of pro_list) { - if (!(pro_id in scores.scores)) { + for (const pro_order of pro_list) { + if (!(pro_order in scores.scores)) { each_problems_score_html += `

-

`; } else { - let score = scores.scores[pro_id]; + let score = scores.scores[pro_order]; let score_idx = parseInt(score.score / 10); if (score_idx > 10) score_idx = 10; if (score_idx < 0) score_idx = 0; From 5b5a5b1cc36afdc0b548a3703c73e06886050c8e Mon Sep 17 00:00:00 2001 From: tobiichi3227 Date: Wed, 28 Jan 2026 21:54:47 +0800 Subject: [PATCH 4/4] chore(contest): make code more readable --- src/handlers/contests/proset.py | 49 ++++++++++++++++----------------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/src/handlers/contests/proset.py b/src/handlers/contests/proset.py index 662af9fd7..cb9f21cb1 100644 --- a/src/handlers/contests/proset.py +++ b/src/handlers/contests/proset.py @@ -62,35 +62,34 @@ async def get(self): elif self.contest.is_running() and not self.contest.is_member(self.acct): return self.error(PERMISSION_DENIED_ERROR) - else: - if self.contest.contest_mode == ContestMode.RANDOM_SET and not self.contest.is_admin(self.acct): - return await self.randomset_proset(pageoff) + elif self.contest.contest_mode == ContestMode.RANDOM_SET and not self.contest.is_admin(self.acct): + return await self.randomset_proset(pageoff) - _, acct_rates = await RateService.inst.map_rate_acct(self.acct, contest_id=self.contest.contest_id) - _, prolist = await ProService.inst.list_pro(ProConst.PRO_STATUS_CONTEST_USER) + _, acct_rates = await RateService.inst.map_rate_acct(self.acct, contest_id=self.contest.contest_id) + _, prolist = await ProService.inst.list_pro(ProConst.PRO_STATUS_CONTEST_USER) - prolist_order = {pro_id: idx for idx, pro_id in enumerate(self.contest.pro_list.keys())} - prolist = sorted(filter(lambda pro: self.contest.is_pro(pro.pro_id), prolist), - key=lambda pro: prolist_order[pro.pro_id]) + prolist_order = {pro_id: idx for idx, pro_id in enumerate(self.contest.pro_list.keys())} + prolist = sorted(filter(lambda pro: self.contest.is_pro(pro.pro_id), prolist), + key=lambda pro: prolist_order[pro.pro_id]) - score_map: dict[int, dict] = {} + score_map: dict[int, dict] = {} + for pro in prolist: + pro_id = pro.pro_id + score_map[pro_id] = {'score': 0, 'state': None} + if pro_id in acct_rates: + score_map[pro_id]['score'] += acct_rates[pro.pro_id]['rate'] + score_map[pro_id]['state'] = acct_rates[pro.pro_id]['state'] + + if self.contest.is_public_scoreboard or self.contest.is_admin(self.acct): + show_ac_ratio = True for pro in prolist: - pro_id = pro.pro_id - score_map[pro_id] = {'score': 0, 'state': None} - if pro_id in acct_rates: - score_map[pro_id]['score'] += acct_rates[pro.pro_id]['rate'] - score_map[pro_id]['state'] = acct_rates[pro.pro_id]['state'] - - if self.contest.is_public_scoreboard or self.contest.is_admin(self.acct): - show_ac_ratio = True - for pro in prolist: - _, rate = await RateService.inst.get_pro_ac_rate(pro.pro_id, contest_id=self.contest.contest_id) - score_map[pro.pro_id]['rate_data'] = rate - - if self.contest.contest_mode == ContestMode.RANDOM_SET: - pro_idx_map = {pro_id: idx for idx, pro_set in enumerate(self.contest.pro_sets) for pro_id in pro_set} - else: - pro_idx_map = {pro_id: idx for idx, pro_id in enumerate(self.contest.pro_list.keys())} + _, rate = await RateService.inst.get_pro_ac_rate(pro.pro_id, contest_id=self.contest.contest_id) + score_map[pro.pro_id]['rate_data'] = rate + + if self.contest.contest_mode == ContestMode.RANDOM_SET: + pro_idx_map = {pro_id: idx for idx, pro_set in enumerate(self.contest.pro_sets) for pro_id in pro_set} + else: + pro_idx_map = {pro_id: idx for idx, pro_id in enumerate(self.contest.pro_list.keys())} pro_total_cnt = len(prolist) prolist = prolist[pageoff: pageoff + 40]