Skip to content
Open
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
3 changes: 3 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------------------
Expand Down
24 changes: 22 additions & 2 deletions doc/manual/running.rst
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,29 @@ 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

.. 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.
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.

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
-----------------
Expand Down
9 changes: 7 additions & 2 deletions etc/db-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,16 @@
default_value: false
public: true
description: Show results of TOO-LATE submissions in team interface?
- name: show_balloons_postfreeze
- name: minimum_number_of_balloons
type: int
default_value: 0
public: true
description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze.
description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze by other teams.
Suggested change
description: How many balloons to hand out ignoring freeze time. Only hands out balloons for problems solved pre-freeze.
description: How many balloons to hand out ignoring freeze time. Limited on problems solved pre-freeze.

- name: any_balloon_postfreeze
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this option different than #Balloons>#Problems?

If the intent is to disclose all the information it's currently unclear.

type: bool
default_value: false
public: true
description: Give out balloon notifications after the scoreboard has been frozen?
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
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/Controller/Jury/BalloonController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions webapp/src/Controller/Jury/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)) {
Expand Down
119 changes: 71 additions & 48 deletions webapp/src/Service/BalloonService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -114,28 +112,22 @@ 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');
$ignorePreFreezeSolves = (bool)$this->config->get('any_balloon_postfreeze');
$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;
Expand All @@ -144,41 +136,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 ($ignorePreFreezeSolves === false && 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
Expand Down
23 changes: 23 additions & 0 deletions webapp/src/Service/CheckConfigService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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__);
Expand Down
29 changes: 9 additions & 20 deletions webapp/src/Service/DOMJudgeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 [
Expand Down
Loading