From 18a8c14e54ce744a403af84c6a8e15b3d746f7b2 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 16 Dec 2025 17:39:20 +0100 Subject: [PATCH 1/6] Reworks balloon for sending balloons in freeze Balloons can be quite a motivator for a team and it can be frustrating to spend >4 hours solving a problem to only get an AC during the freeze. We used to have the show_balloons_postfreeze configflag to send balloons during the freeze but this would always send balloons during the freeze. show_balloons_postfreeze has now been replaced with 'minimum_number_of_balloons'. This setting now expresses the minimum number of balloons to send to a team even during the freeze. To prevent an information leak only balloons for problems that have been solved before the freeze can be sent out. Leaving this value to 0 keeps the 'old' behavior of not sending any balloons while setting it to a value >#contestproblems results in the old behaviour of sending all balloons during the freeze. --- etc/db-config.yaml | 8 +- webapp/src/Service/BalloonService.php | 118 +++++++++++++++----------- 2 files changed, 74 insertions(+), 52 deletions(-) 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..0868612994 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']; + $minimumNumberOfBalloons = (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 (count($relevantBalloonSummaries[$balloonsData['teamid']] ?? []) >= $minimumNumberOfBalloons) { + continue; + } + + // Check if problem has been solved before the freeze by someone in the same sortorder to prevent information leak. + // Checking for solved problems in the same sortorder prevent information leaks from teams like DOMjudge that have commonly solved + // all problems (jury solutions) but are not in the same sortorder. 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 From e448728bbdb4a3604712b79f25800d768f7c9992 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 16 Dec 2025 17:47:39 +0100 Subject: [PATCH 2/6] Uses the baloonservice for the updates The computation for which balloons need to be sent out is a bit more complex than before. Reusing the logic of the BalloonService keeps it nice and DRY. --- webapp/src/Service/DOMJudgeService.php | 29 ++++++++------------------ 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index fca9cd5eb8..7715aaaad4 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 [ From d65abadfa1d88f3c01c72a53efc5ad336a531181 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 16 Dec 2025 18:03:03 +0100 Subject: [PATCH 3/6] Updates documentation for the new config variable --- doc/manual/running.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ----------------- From b2e9386450a8f69d639ff0bf30f10562367b2822 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 23 Dec 2025 19:04:25 +0100 Subject: [PATCH 4/6] Reintroduces old post-freeze balloon behavior The old behavior allowed handing out all balloon notifications during the freeze regardless of whether the problem was solved before the freeze. Instead of reusing the old configuration variable a new variable is chosen to better reflect the interaction with `minimum_number_of_balloons`. --- doc/manual/running.rst | 7 +++++++ etc/db-config.yaml | 5 +++++ webapp/src/Service/BalloonService.php | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/doc/manual/running.rst b/doc/manual/running.rst index d3d5b4b85f..dc70e68a99 100644 --- a/doc/manual/running.rst +++ b/doc/manual/running.rst @@ -113,6 +113,13 @@ 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. +To hand balloons for any and all correct submissions during the freeze +set the ``any_balloon_postfreeze`` global configuration option to `true`. +This sends out balloons as long as ``minimum_number_of_balloons`` has not +been met. By setting ``minimum_number_of_balloons`` to a value greater or +equal to the number of problems in the contest ensures any submission +results in a balloon notification. + Static scoreboard ----------------- The public scoreboard can be output in 'static' form meaning it does diff --git a/etc/db-config.yaml b/etc/db-config.yaml index 3fab62857a..4c2855c388 100644 --- a/etc/db-config.yaml +++ b/etc/db-config.yaml @@ -260,6 +260,11 @@ default_value: 0 public: true description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze. + - name: any_balloon_postfreeze + type: bool + default_value: false + public: true + description: Hand out balloons during the freeze for problems that have not been 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 0868612994..9bdb86fc26 100644 --- a/webapp/src/Service/BalloonService.php +++ b/webapp/src/Service/BalloonService.php @@ -118,6 +118,7 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array $balloons = $query->getQuery()->getResult(); $minimumNumberOfBalloons = (int)$this->config->get('minimum_number_of_balloons'); + $ignorePreFreezeSolves = (bool)$this->config->get('any_balloon_postfreeze'); $freezetime = $contest->getFreezeTime(); $balloonsTable = []; @@ -148,7 +149,7 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array // 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 ($ignorePreFreezeSolves === false && isset($freezetime) && $stime >= $freezetime) { if (count($relevantBalloonSummaries[$balloonsData['teamid']] ?? []) >= $minimumNumberOfBalloons) { continue; } From 6d58ebd3db4c08621f0ec3484559768bb7eed201 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 23 Dec 2025 19:15:02 +0100 Subject: [PATCH 5/6] Dissuade handing out post-freeze balloons --- doc/manual/running.rst | 10 ++++++++ .../src/Controller/Jury/BalloonController.php | 4 ++++ .../src/Controller/Jury/ConfigController.php | 5 ++++ webapp/src/Service/CheckConfigService.php | 23 +++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/doc/manual/running.rst b/doc/manual/running.rst index dc70e68a99..cc36b48dc3 100644 --- a/doc/manual/running.rst +++ b/doc/manual/running.rst @@ -106,6 +106,16 @@ 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`). + +.. warning:: + + Using the features as described below results in data inconsistencies. + e.g. balloon notifications are available through the API while no + judgement is available. Using these features can also lead to undesirable + information leaking to contestants and observers. Use with caution! + +Balloons during frozen scoreboard +````````````````````` 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. This only diff --git a/webapp/src/Controller/Jury/BalloonController.php b/webapp/src/Controller/Jury/BalloonController.php index 9098772310..5b250a27b5 100644 --- a/webapp/src/Controller/Jury/BalloonController.php +++ b/webapp/src/Controller/Jury/BalloonController.php @@ -52,6 +52,10 @@ private function areDefault(array $filters, array $defaultCategories): bool { #[Route(path: '', name: 'jury_balloons')] public function indexAction(BalloonService $balloonService): Response { + if (((int)$this->config->get('minimum_number_of_balloons')) !== 0) { + $this->addFlash('warning', 'Minimum number of balloons is enabled, this leads to data inconsistencies and/or information leaking during the freeze. Can be disabled in the "Configuration settings".'); + } + $contest = $this->dj->getCurrentContest(); if (is_null($contest)) { return $this->render('jury/balloons.html.twig'); diff --git a/webapp/src/Controller/Jury/ConfigController.php b/webapp/src/Controller/Jury/ConfigController.php index a81fcdc531..f670a48afd 100644 --- a/webapp/src/Controller/Jury/ConfigController.php +++ b/webapp/src/Controller/Jury/ConfigController.php @@ -38,6 +38,7 @@ public function indexAction(EventLogService $eventLogService, Request $request): foreach ($specs as &$spec) { $spec = $this->config->addOptions($spec); } + unset($spec); /** @var Configuration[] $options */ $options = $this->em->createQueryBuilder() @@ -123,6 +124,10 @@ public function indexAction(EventLogService $eventLogService, Request $request): } } + if (((int)$this->config->get('minimum_number_of_balloons')) !== 0) { + $this->addFlash('warning', 'Minimum number of balloons is enabled, this leads to data inconsistencies and/or information leaking during the freeze. Can be disabled in the "Display" tab.'); + } + $categories = []; foreach ($specs as $spec) { if (!in_array($spec->category, $categories)) { diff --git a/webapp/src/Service/CheckConfigService.php b/webapp/src/Service/CheckConfigService.php index a3e002073f..3190d29a29 100644 --- a/webapp/src/Service/CheckConfigService.php +++ b/webapp/src/Service/CheckConfigService.php @@ -67,6 +67,7 @@ public function runAll(): array 'debugdisabled' => $this->checkDebugDisabled(), 'tmpdirwritable' => $this->checkTmpdirWritable(), 'hashtime' => $this->checkHashTime(), + 'balloonsduringfreeze' => $this->checkBalloonsDuringFreeze(), ]; foreach (['affiliations', 'banners', 'countries', 'teams'] as $key) { @@ -476,6 +477,28 @@ public function checkHashTime(): ConfigCheckItem ); } + public function checkBalloonsDuringFreeze(): ConfigCheckItem + { + $balloonsDuringFreeze = (int)$this->config->get('minimum_number_of_balloons'); + + $desc = '- Handing out any balloons while the scoreboard is frozen can lead to data inconsistencies and information leaking.' + . sprintf("\n - Currently handing out up-to `%d` balloons during the freeze.", $balloonsDuringFreeze); + + if ($balloonsDuringFreeze !== 0) { + return new ConfigCheckItem( + caption: 'Balloons during freeze', + result: 'W', + desc: $desc, + ); + } + + return new ConfigCheckItem( + caption: 'Balloons during freeze', + result: 'O', + desc: $desc, + ); + } + public function checkContestActive(): ConfigCheckItem { $this->stopwatch->start(__FUNCTION__); From fe178e5b96a7683887f399adcad9e6cecf962455 Mon Sep 17 00:00:00 2001 From: Mart Pluijmaekers Date: Tue, 23 Dec 2025 21:51:04 +0100 Subject: [PATCH 6/6] Adds new config variables to changelog --- ChangeLog | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ChangeLog b/ChangeLog index 39bcd6f2c6..28396543f9 100644 --- a/ChangeLog +++ b/ChangeLog @@ -2,6 +2,9 @@ DOMjudge Programming Contest Judging System Version 10.0.0DEV --------------------------- + - Configuration changes + - `show_balloons_postfreeze` configuration variable superseded by + `minimum_number_of_balloons` and `any_balloon_postfreeze`. Version 9.0.0 - 5 October 2025 ---------------------------