@@ -92,15 +92,13 @@ public function updateBalloons(
9292 public function collectBalloonTable (Contest $ contest , bool $ todo = false ): array
9393 {
9494 $ em = $ this ->em ;
95- $ showPostFreeze = (bool )$ this ->config ->get ('show_balloons_postfreeze ' );
96- if (!$ showPostFreeze ) {
97- $ freezetime = $ contest ->getFreezeTime ();
98- }
9995
96+ // Retrieve all relevant balloons in 'submit order'. This allows accurate
97+ // counts when deciding whether to hand out post-freeze balloons.
10098 $ query = $ em ->createQueryBuilder ()
10199 ->select ('b ' , 's.submittime ' , 'p.probid ' ,
102100 't.teamid ' , 's ' , 't ' , 't.location ' ,
103- 'c.categoryid AS categoryid ' , 'c.name AS catname ' ,
101+ 'c.categoryid AS categoryid ' , 'c.sortorder ' , ' c. name AS catname ' ,
104102 'co.cid ' , 'co.shortname ' ,
105103 'cp.shortname AS probshortname ' , 'cp.color ' ,
106104 'a.affilid AS affilid ' , 'a.shortname AS affilshort ' )
@@ -114,28 +112,20 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array
114112 ->leftJoin ('t.affiliation ' , 'a ' )
115113 ->andWhere ('co.cid = :cid ' )
116114 ->setParameter ('cid ' , $ contest ->getCid ())
117- ->orderBy ('b.done ' , 'ASC ' )
118- ->addOrderBy ('s.submittime ' , 'DESC ' );
115+ ->orderBy ('b.done ' , 'DESC ' )
116+ ->addOrderBy ('s.submittime ' , 'ASC ' );
119117
120118 $ balloons = $ query ->getQuery ()->getResult ();
121- // Loop once over the results to get totals.
122- $ TOTAL_BALLOONS = [];
123- foreach ($ balloons as $ balloonsData ) {
124- if ($ balloonsData ['color ' ] === null ) {
125- continue ;
126- }
127119
128- $ stime = $ balloonsData ['submittime ' ];
120+ $ minumumNumberOfBalloons = (int )$ this ->config ->get ('minimum_number_of_balloons ' );
121+ $ freezetime = $ contest ->getFreezeTime ();
129122
130- if (isset ($ freezetime ) && $ stime >= $ freezetime ) {
131- continue ;
132- }
123+ $ balloonsTable = [];
133124
134- $ TOTAL_BALLOONS [$ balloonsData ['teamid ' ]][$ balloonsData ['probshortname ' ]] = $ balloonsData [0 ]->getSubmission ()->getContestProblem ();
135- }
125+ // Total balloons keeps track of the total balloons for a team, will be used to fill the rhs for every row in $balloonsTable.
126+ // The same summary is used for every row for a team. References to elements in this array ensure easy updates.
127+ $ balloonSummaryPerTeam = [];
136128
137- // Loop again to construct table.
138- $ balloons_table = [];
139129 foreach ($ balloons as $ balloonsData ) {
140130 if ($ balloonsData ['color ' ] === null ) {
141131 continue ;
@@ -144,41 +134,72 @@ public function collectBalloonTable(Contest $contest, bool $todo = false): array
144134 $ balloon = $ balloonsData [0 ];
145135 $ done = $ balloon ->getDone ();
146136
147- if ($ todo && $ done ) {
148- continue ;
137+ // Ensure a summary-row exists for this sortorder and take a reference to these summaries. References are needed to ensure array reuse.
138+ // Summaries are used to determine whether a balloon has been handed out so they need to be separated between sortorders.
139+ $ balloonSummaryPerTeam [$ balloonsData ['sortorder ' ]] ??= [];
140+ $ relevantBalloonSummaries = &$ balloonSummaryPerTeam [$ balloonsData ['sortorder ' ]];
141+
142+ // Commonly no balloons are handed out post freeze.
143+ // Underperforming teams' moral can be boosted by handing out balloons post-freeze.
144+ // Handing out balloons for problems that have not been solved pre-freeze poses a potential information leak, so these are always excluded.
145+ // So to decide whether to skip showing a balloon:
146+ // 1. Check whether the scoreboard has been frozen.
147+ // 2. Check whether the team has exceeded minimum number of balloons.
148+ // 3. Check whether the problem been solved pre-freeze.
149+ $ stime = $ balloonsData ['submittime ' ];
150+ if (isset ($ freezetime ) && $ stime >= $ freezetime ) {
151+ if (key_exists ($ balloonsData ['teamid ' ], $ relevantBalloonSummaries ) &&
152+ count ($ relevantBalloonSummaries [$ balloonsData ['teamid ' ]]) >= $ minumumNumberOfBalloons ) {
153+ continue ;
154+ }
155+
156+ // Check if problem has been solved before the freeze by someone in the same sortorder to prevent information leak.
157+ // The DOMjudge team (that commonly runs jury submissions) has so must be ignored.
158+ // 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'.
159+ if (!array_reduce ($ relevantBalloonSummaries , fn ($ c , $ i ) => $ c ||
160+ array_key_exists ($ balloonsData ['probshortname ' ], $ i ), false )) {
161+ continue ;
162+ }
149163 }
150164
151- $ balloonId = $ balloon ->getBalloonId ();
165+ // Register the balloon that is handed out in the team summary.
166+ $ relevantBalloonSummaries [$ balloonsData ['teamid ' ]][$ balloonsData ['probshortname ' ]] = $ balloon ->getSubmission ()->getContestProblem ();
152167
153- $ stime = $ balloonsData ['submittime ' ];
154-
155- if (isset ($ freezetime ) && $ stime >= $ freezetime ) {
168+ // This balloon might not need to be listed, entire order is needed for counts though.
169+ if ($ todo && $ done ) {
156170 continue ;
157171 }
158172
159- $ balloondata = [];
160- $ balloondata ['balloonid ' ] = $ balloonId ;
161- $ balloondata ['time ' ] = $ stime ;
162- $ balloondata ['problem ' ] = $ balloonsData ['probshortname ' ];
163- $ balloondata ['contestproblem ' ] = $ balloon ->getSubmission ()->getContestProblem ();
164- $ balloondata ['team ' ] = $ balloon ->getSubmission ()->getTeam ();
165- $ balloondata ['teamid ' ] = $ balloonsData ['teamid ' ];
166- $ balloondata ['location ' ] = $ balloonsData ['location ' ];
167- $ balloondata ['affiliation ' ] = $ balloonsData ['affilshort ' ];
168- $ balloondata ['affiliationid ' ] = $ balloonsData ['affilid ' ];
169- $ balloondata ['category ' ] = $ balloonsData ['catname ' ];
170- $ balloondata ['categoryid ' ] = $ balloonsData ['categoryid ' ];
171-
172- ksort ($ TOTAL_BALLOONS [$ balloonsData ['teamid ' ]]);
173- $ balloondata ['total ' ] = $ TOTAL_BALLOONS [$ balloonsData ['teamid ' ]];
174-
175- $ balloondata ['done ' ] = $ done ;
176-
177- $ balloons_table [] = [
178- 'data ' => $ balloondata ,
173+ $ balloonsTable [] = [
174+ 'data ' => [
175+ 'balloonid ' => $ balloon ->getBalloonId (),
176+ 'time ' => $ stime ,
177+ 'problem ' => $ balloonsData ['probshortname ' ],
178+ 'contestproblem ' => $ balloon ->getSubmission ()->getContestProblem (),
179+ 'team ' => $ balloon ->getSubmission ()->getTeam (),
180+ 'teamid ' => $ balloonsData ['teamid ' ],
181+ 'location ' => $ balloonsData ['location ' ],
182+ 'affiliation ' => $ balloonsData ['affilshort ' ],
183+ 'affiliationid ' => $ balloonsData ['affilid ' ],
184+ 'category ' => $ balloonsData ['catname ' ],
185+ 'categoryid ' => $ balloonsData ['categoryid ' ],
186+ 'done ' => $ done ,
187+
188+ // Reuse the same total summary table by taking a reference, makes updates easier.
189+ 'total ' => &$ relevantBalloonSummaries [$ balloonsData ['teamid ' ]],
190+ ]
179191 ];
180192 }
181- return $ balloons_table ;
193+
194+ // Sort the balloons, since these are handled by reference each summary item only need to be sorted once.
195+ foreach ($ balloonSummaryPerTeam as $ relevantBalloonSummaries ) {
196+ foreach ($ relevantBalloonSummaries as &$ balloons ) {
197+ ksort ($ balloons );
198+ }
199+ }
200+
201+ // Reverse the order so the newest appear first
202+ return array_reverse ($ balloonsTable );
182203 }
183204
184205 public function setDone (int $ balloonId ): void
0 commit comments