diff --git a/doc/manual/running.rst b/doc/manual/running.rst index 931a8a4756..d3d5b4b85f 100644 --- a/doc/manual/running.rst +++ b/doc/manual/running.rst @@ -106,9 +106,12 @@ show for submissions after the freeze. It is possible that new entries appear for some times after the freeze, if the result of a submission before the freeze is only known after (this can also happen in case of a :ref:`rejudging`). -The global configuration option ``show_balloons_postfreeze`` will +The global configuration option ``minimum_number_of_balloons`` will ignore a contest freeze for purposes of balloons and new correct -submissions will trigger a balloon entry in the table. +submissions will trigger a balloon entry in the table. This only +happens when the team problem has not received the amount of balloons +set by the configuration option and the newly solved problem must have +been solved before the freeze. This is to prevent an information leak. Static scoreboard ----------------- diff --git a/etc/db-config.yaml b/etc/db-config.yaml index f5625dbaf6..3fab62857a 100644 --- a/etc/db-config.yaml +++ b/etc/db-config.yaml @@ -255,11 +255,11 @@ default_value: false public: true description: Show results of TOO-LATE submissions in team interface? - - name: show_balloons_postfreeze - type: bool - default_value: false + - name: minimum_number_of_balloons + type: int + default_value: 0 public: true - description: Give out balloon notifications after the scoreboard has been frozen? + description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze. - name: show_relative_time type: bool default_value: false diff --git a/webapp/src/Service/BalloonService.php b/webapp/src/Service/BalloonService.php index 2b3dad6d13..1c47adf3c4 100644 --- a/webapp/src/Service/BalloonService.php +++ b/webapp/src/Service/BalloonService.php @@ -92,15 +92,13 @@ public function updateBalloons( public function collectBalloonTable(Contest $contest, bool $todo = false): array { $em = $this->em; - $showPostFreeze = (bool)$this->config->get('show_balloons_postfreeze'); - if (!$showPostFreeze) { - $freezetime = $contest->getFreezeTime(); - } + // Retrieve all relevant balloons in 'submit order'. This allows accurate + // counts when deciding whether to hand out post-freeze balloons. $query = $em->createQueryBuilder() ->select('b', 's.submittime', 'p.probid', 't.teamid', 's', 't', 't.location', - 'c.categoryid AS categoryid', 'c.name AS catname', + 'c.categoryid AS categoryid', 'c.sortorder', 'c.name AS catname', 'co.cid', 'co.shortname', 'cp.shortname AS probshortname', 'cp.color', 'a.affilid AS affilid', 'a.shortname AS affilshort') @@ -114,28 +112,21 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array ->leftJoin('t.affiliation', 'a') ->andWhere('co.cid = :cid') ->setParameter('cid', $contest->getCid()) - ->orderBy('b.done', 'ASC') - ->addOrderBy('s.submittime', 'DESC'); + ->orderBy('b.done', 'DESC') + ->addOrderBy('s.submittime', 'ASC'); $balloons = $query->getQuery()->getResult(); - // Loop once over the results to get totals. - $TOTAL_BALLOONS = []; - foreach ($balloons as $balloonsData) { - if ($balloonsData['color'] === null) { - continue; - } - $stime = $balloonsData['submittime']; + $minumumNumberOfBalloons = (int)$this->config->get('minimum_number_of_balloons'); + $freezetime = $contest->getFreezeTime(); - if (isset($freezetime) && $stime >= $freezetime) { - continue; - } + $balloonsTable = []; - $TOTAL_BALLOONS[$balloonsData['teamid']][$balloonsData['probshortname']] = $balloonsData[0]->getSubmission()->getContestProblem(); - } + // Total balloons keeps track of the total balloons for a team, will be used to fill the rhs for every row in $balloonsTable. + // The same summary is used for every row for a team. References to elements in this array ensure easy updates. + /** @var mixed[] $balloonSummaryPerTeam */ + $balloonSummaryPerTeam = []; - // Loop again to construct table. - $balloons_table = []; foreach ($balloons as $balloonsData) { if ($balloonsData['color'] === null) { continue; @@ -144,41 +135,72 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array $balloon = $balloonsData[0]; $done = $balloon->getDone(); - if ($todo && $done) { - continue; + // Ensure a summary-row exists for this sortorder and take a reference to these summaries. References are needed to ensure array reuse. + // Summaries are used to determine whether a balloon has been handed out so they need to be separated between sortorders. + $balloonSummaryPerTeam[$balloonsData['sortorder']] ??= []; + $relevantBalloonSummaries = &$balloonSummaryPerTeam[$balloonsData['sortorder']]; + + // Commonly no balloons are handed out post freeze. + // Underperforming teams' moral can be boosted by handing out balloons post-freeze. + // Handing out balloons for problems that have not been solved pre-freeze poses a potential information leak, so these are always excluded. + // So to decide whether to skip showing a balloon: + // 1. Check whether the scoreboard has been frozen. + // 2. Check whether the team has exceeded minimum number of balloons. + // 3. Check whether the problem been solved pre-freeze. + $stime = $balloonsData['submittime']; + if (isset($freezetime) && $stime >= $freezetime) { + if (key_exists($balloonsData['teamid'], $relevantBalloonSummaries) && + count($relevantBalloonSummaries[$balloonsData['teamid']]) >= $minumumNumberOfBalloons) { + continue; + } + + // Check if problem has been solved before the freeze by someone in the same sortorder to prevent information leak. + // The DOMjudge team (that commonly runs jury submissions) has so must be ignored. + // If a balloon for this problem should've been handed out it is safe to hand out again since balloons are handled in 'submit order'. + if (!array_reduce($relevantBalloonSummaries, fn($c, $i) => $c || + array_key_exists($balloonsData['probshortname'], $i), false)) { + continue; + } } - $balloonId = $balloon->getBalloonId(); + // Register the balloon that is handed out in the team summary. + $relevantBalloonSummaries[$balloonsData['teamid']][$balloonsData['probshortname']] = $balloon->getSubmission()->getContestProblem(); - $stime = $balloonsData['submittime']; - - if (isset($freezetime) && $stime >= $freezetime) { + // This balloon might not need to be listed, entire order is needed for counts though. + if ($todo && $done) { continue; } - $balloondata = []; - $balloondata['balloonid'] = $balloonId; - $balloondata['time'] = $stime; - $balloondata['problem'] = $balloonsData['probshortname']; - $balloondata['contestproblem'] = $balloon->getSubmission()->getContestProblem(); - $balloondata['team'] = $balloon->getSubmission()->getTeam(); - $balloondata['teamid'] = $balloonsData['teamid']; - $balloondata['location'] = $balloonsData['location']; - $balloondata['affiliation'] = $balloonsData['affilshort']; - $balloondata['affiliationid'] = $balloonsData['affilid']; - $balloondata['category'] = $balloonsData['catname']; - $balloondata['categoryid'] = $balloonsData['categoryid']; - - ksort($TOTAL_BALLOONS[$balloonsData['teamid']]); - $balloondata['total'] = $TOTAL_BALLOONS[$balloonsData['teamid']]; - - $balloondata['done'] = $done; - - $balloons_table[] = [ - 'data' => $balloondata, + $balloonsTable[] = [ + 'data' => [ + 'balloonid' => $balloon->getBalloonId(), + 'time' => $stime, + 'problem' => $balloonsData['probshortname'], + 'contestproblem' => $balloon->getSubmission()->getContestProblem(), + 'team' => $balloon->getSubmission()->getTeam(), + 'teamid' => $balloonsData['teamid'], + 'location' => $balloonsData['location'], + 'affiliation' => $balloonsData['affilshort'], + 'affiliationid' => $balloonsData['affilid'], + 'category' => $balloonsData['catname'], + 'categoryid' => $balloonsData['categoryid'], + 'done' => $done, + + // Reuse the same total summary table by taking a reference, makes updates easier. + 'total' => &$relevantBalloonSummaries[$balloonsData['teamid']], + ] ]; } - return $balloons_table; + + // Sort the balloons, since these are handled by reference each summary item only need to be sorted once. + foreach ($balloonSummaryPerTeam as $relevantBalloonSummaries) { + foreach ($relevantBalloonSummaries as &$balloons) { + ksort($balloons); + } + } + + // Reverse the order so the newest appear first + return array_reverse($balloonsTable); } public function setDone(int $balloonId): void diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index 994a8c407a..786c962dc3 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -101,6 +101,7 @@ class DOMJudgeService public function __construct( protected readonly EntityManagerInterface $em, + protected readonly BalloonService $balloonService, protected readonly LoggerInterface $logger, protected readonly RequestStack $requestStack, protected readonly ParameterBagInterface $params, @@ -437,26 +438,14 @@ public function getUpdates(): array } if ($this->checkrole('balloon') && $contest) { - $balloonsQuery = $this->em->createQueryBuilder() - ->select('b.balloonid', 't.name', 't.location', 'p.name AS pname') - ->from(Balloon::class, 'b') - ->leftJoin('b.submission', 's') - ->leftJoin('s.problem', 'p') - ->leftJoin('s.contest', 'co') - ->leftJoin('p.contest_problems', 'cp', Join::WITH, 'co.cid = cp.contest AND p.probid = cp.problem') - ->leftJoin('s.team', 't') - ->andWhere('co.cid = :cid') - ->andWhere('b.done = 0') - ->setParameter('cid', $contest->getCid()); - - $freezetime = $contest->getFreezeTime(); - if ($freezetime !== null && !(bool)$this->config->get('show_balloons_postfreeze')) { - $balloonsQuery - ->andWhere('s.submittime < :freeze') - ->setParameter('freeze', $freezetime); - } - - $balloons = $balloonsQuery->getQuery()->getResult(); + $balloons = array_map(function ($balloon) { + return [ + 'balloonid' => $balloon['data']['balloonid'], + 'name' => $balloon['data']['team']->getName(), + 'location' => $balloon['data']['location'], + 'pname' => $balloon['data']['contestproblem']->getProblem()->getName(), + ]; + }, $this->balloonService->collectBalloonTable($contest, true)); } return [