diff --git a/doc/manual/install-domserver.rst b/doc/manual/install-domserver.rst index 16ede690d0..f862a80a91 100644 --- a/doc/manual/install-domserver.rst +++ b/doc/manual/install-domserver.rst @@ -37,14 +37,14 @@ GNU/Linux, or one of its derivative distributions like Ubuntu:: sudo apt install libcgroup-dev make acl zip unzip pv mariadb-server apache2 \ php php-fpm php-gd php-cli php-intl php-mbstring php-mysql \ - php-curl php-json php-xml php-zip composer ntp python3-yaml + php-curl php-json php-xml php-zip composer ntp python3-yaml php-bcmath The following command can be used on Fedora, and related distributions like Red Hat Enterprise Linux and Rocky Linux (before V9):: sudo dnf install libcgroup-devel make acl zip unzip pv mariadb-server httpd \ php-gd php-cli php-intl php-mbstring php-mysqlnd php-fpm \ - php-xml php-zip composer chronyd python3-pyyaml + php-xml php-zip composer chronyd python3-pyyaml php-bcmath `nginx` can be used as an alternate web server. diff --git a/webapp/composer.json b/webapp/composer.json index 760cb5e441..6186a22e70 100644 --- a/webapp/composer.json +++ b/webapp/composer.json @@ -40,6 +40,7 @@ ], "require": { "php": "^8.1.0", + "ext-bcmath": "*", "ext-ctype": "*", "ext-curl": "*", "ext-fileinfo": "*", diff --git a/webapp/composer.lock b/webapp/composer.lock index 24485370c8..204dd4e86a 100644 --- a/webapp/composer.lock +++ b/webapp/composer.lock @@ -12843,6 +12843,7 @@ "prefer-lowest": false, "platform": { "php": "^8.1.0", + "ext-bcmath": "*", "ext-ctype": "*", "ext-curl": "*", "ext-fileinfo": "*", diff --git a/webapp/migrations/Version20250309122806.php b/webapp/migrations/Version20250309122806.php new file mode 100644 index 0000000000..d61567510e --- /dev/null +++ b/webapp/migrations/Version20250309122806.php @@ -0,0 +1,40 @@ +addSql('ALTER TABLE rankcache ADD sort_key_public TEXT DEFAULT \'\' NOT NULL COMMENT \'Opaque sort key for public audience.\', ADD sort_key_restricted TEXT DEFAULT \'\' NOT NULL COMMENT \'Opaque sort key for restricted audience.\''); + $this->addSql('CREATE INDEX sortKeyPublic ON rankcache (sort_key_public)'); + $this->addSql('CREATE INDEX sortKeyRestricted ON rankcache (sort_key_restricted)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX sortKeyPublic ON rankcache'); + $this->addSql('DROP INDEX sortKeyRestricted ON rankcache'); + $this->addSql('ALTER TABLE rankcache DROP sort_key_public, DROP sort_key_restricted'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/Jury/ImportExportController.php b/webapp/src/Controller/Jury/ImportExportController.php index c489a9da2a..6f07dfd71a 100644 --- a/webapp/src/Controller/Jury/ImportExportController.php +++ b/webapp/src/Controller/Jury/ImportExportController.php @@ -412,7 +412,7 @@ protected function getResultsHtml( $filter = new Filter(); $filter->categories = $categoryIds; $scoreboard = $this->scoreboardService->getScoreboard($contest, true, $filter); - $teams = $scoreboard->getTeams(); + $teams = $scoreboard->getTeamsInDescendingOrder(); $teamNames = []; foreach ($teams as $team) { diff --git a/webapp/src/Entity/RankCache.php b/webapp/src/Entity/RankCache.php index dfe7f6d9e4..d1f25640eb 100644 --- a/webapp/src/Entity/RankCache.php +++ b/webapp/src/Entity/RankCache.php @@ -1,6 +1,7 @@ 'Opaque sort key for public audience.', 'default' => ''] + )] + private string $sortKeyPublic = ''; + + #[ORM\Column( + type: 'text', + length: AbstractMySQLPlatform::LENGTH_LIMIT_TEXT, + options: ['comment' => 'Opaque sort key for restricted audience.', 'default' => ''] + )] + private string $sortKeyRestricted = ''; + public function setPointsRestricted(int $pointsRestricted): RankCache { $this->points_restricted = $pointsRestricted; @@ -149,4 +166,26 @@ public function getTeam(): Team { return $this->team; } + + public function setSortKeyPublic(string $sortKeyPublic): RankCache + { + $this->sortKeyPublic = $sortKeyPublic; + return $this; + } + + public function getSortKeyPublic(): string + { + return $this->sortKeyPublic; + } + + public function setSortKeyRestricted(string $sortKeyRestricted): RankCache + { + $this->sortKeyRestricted = $sortKeyRestricted; + return $this; + } + + public function getSortKeyRestricted(): string + { + return $this->sortKeyRestricted; + } } diff --git a/webapp/src/Service/AwardService.php b/webapp/src/Service/AwardService.php index 0631d00538..4dd15073f7 100644 --- a/webapp/src/Service/AwardService.php +++ b/webapp/src/Service/AwardService.php @@ -16,7 +16,7 @@ protected function loadAwards(Contest $contest, Scoreboard $scoreboard): void { $group_winners = $problem_winners = $problem_shortname = []; $groups = []; - foreach ($scoreboard->getTeams() as $team) { + foreach ($scoreboard->getTeamsInDescendingOrder() as $team) { $teamid = $team->getExternalid(); if ($scoreboard->isBestInCategory($team)) { $catId = $team->getCategory()->getExternalid(); diff --git a/webapp/src/Service/ScoreboardService.php b/webapp/src/Service/ScoreboardService.php index e0c37c80e2..a5f0012b4f 100644 --- a/webapp/src/Service/ScoreboardService.php +++ b/webapp/src/Service/ScoreboardService.php @@ -19,6 +19,7 @@ use App\Utils\Scoreboard\SingleTeamScoreboard; use App\Utils\Scoreboard\TeamScore; use App\Utils\Utils; +use Doctrine\Common\Collections\Order; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Exception as DBALException; use Doctrine\ORM\EntityManagerInterface; @@ -67,14 +68,15 @@ public function getScoreboard( return null; } - $teams = $this->getTeams($contest, $jury && !$visibleOnly, $filter); + $teams = $this->getTeamsInOrder($contest, $jury && !$visibleOnly, $filter); $problems = $this->getProblems($contest); $categories = $this->getCategories($jury && !$visibleOnly); $scoreCache = $this->getScorecache($contest); + $rankCache = $this->getRankcache($contest); return new Scoreboard( $contest, $teams, $categories, $problems, - $scoreCache, $freezeData, $jury || $forceUnfrozen, + $scoreCache, $rankCache, $freezeData, $jury || $forceUnfrozen, (int)$this->config->get('penalty_time'), (bool)$this->config->get('score_in_seconds'), ); @@ -93,7 +95,7 @@ public function getTeamScoreboard(Contest $contest, int $teamId, bool $showFtsIn { $freezeData = new FreezeData($contest); - $teams = $this->getTeams($contest, true, new Filter([], [], [], [$teamId])); + $teams = $this->getTeamsInOrder($contest, true, new Filter([], [], [], [$teamId])); if (empty($teams)) { return null; } @@ -101,7 +103,7 @@ public function getTeamScoreboard(Contest $contest, int $teamId, bool $showFtsIn $problems = $this->getProblems($contest); $rankCache = $this->getRankcache($contest, $team); $scoreCache = $this->getScorecache($contest, $team); - $teamRank = $this->calculateTeamRank($contest, $team, $rankCache, $freezeData, true); + $teamRank = $this->calculateTeamRank($contest, $team, $freezeData, true); return new SingleTeamScoreboard( $contest, $team, $teamRank, $problems, @@ -120,121 +122,47 @@ public function getTeamScoreboard(Contest $contest, int $teamId, bool $showFtsIn public function calculateTeamRank( Contest $contest, Team $team, - ?RankCache $rankCache = null, ?FreezeData $freezeData = null, bool $jury = false ): int { if ($freezeData === null) { $freezeData = new FreezeData($contest); } - if ($rankCache === null) { - $rankCache = $this->getRankcache($contest, $team); - } $restricted = ($jury || $freezeData->showFinal(false)); - $variant = $restricted ? 'restricted' : 'public'; - $points = $rankCache ? $rankCache->getPointsRestricted() : 0; - $totalTime = 0; - if ($rankCache) { - $totalTime = $contest->getRuntimeAsScoreTiebreaker() ? $rankCache->getTotalruntimeRestricted() : $rankCache->getTotaltimeRestricted(); - } - $timeType = $contest->getRuntimeAsScoreTiebreaker() ? 'runtime' : 'time'; + $variant = $restricted ? 'Restricted' : 'Public'; $sortOrder = $team->getCategory()->getSortorder(); - // Number of teams that definitely ranked higher. + $sortKey = $this->em->createQueryBuilder() + ->from(RankCache::class, 'r') + ->select('r.sortKey'.$variant) + ->andWhere('r.contest = :contest') + ->andWhere('r.team = :team') + ->setParameter('contest', $contest) + ->setParameter('team', $team) + ->getQuery() + ->getOneOrNullResult(); + + if ($sortKey === null) { + // '.' sorts before any digit, so this team will be ranked last (which may be actually rank 1 if no team + // solved anything yet). + $sortKey = "."; + } + $better = $this->em->createQueryBuilder() ->from(RankCache::class, 'r') ->join('r.team', 't') ->join('t.category', 'tc') ->select('COUNT(t.teamid)') + ->andWhere('r.sortKey'.$variant.' > :sortKey') ->andWhere('r.contest = :contest') ->andWhere('tc.sortorder = :sortorder') - ->andWhere('t.enabled = 1') - ->andWhere(sprintf('r.points_%s > :points OR '. - '(r.points_%s = :points AND r.total%s_%s < :totaltime)', - $variant, $variant, $timeType, $variant)) + ->setParameter('sortKey', $sortKey) ->setParameter('contest', $contest) ->setParameter('sortorder', $sortOrder) - ->setParameter('points', $points) - ->setParameter('totaltime', $totalTime) ->getQuery() ->getSingleScalarResult(); $rank = $better + 1; - - // Resolve ties based on latest correctness points, only necessary - // when we actually solved at least one problem, so this list should - // usually be short. - if ($points > 0) { - /** @var RankCache[] $tied */ - $tied = $this->em->createQueryBuilder() - ->from(RankCache::class, 'r') - ->join('r.team', 't') - ->join('t.category', 'tc') - ->select('r, t') - ->andWhere('r.contest = :contest') - ->andWhere('tc.sortorder = :sortorder') - ->andWhere('t.enabled = 1') - ->andWhere(sprintf('r.points_%s = :points AND r.total%s_%s = :totaltime', - $variant, $timeType, $variant)) - ->setParameter('contest', $contest) - ->setParameter('sortorder', $sortOrder) - ->setParameter('points', $points) - ->setParameter('totaltime', $totalTime) - ->getQuery() - ->getResult(); - - // All teams that are tied for this position. In most cases this - // will only be the team we are finding the rank for, only - // retrieve rest of the data when there are actual ties. - if (count($tied) > 1) { - // Initialize team scores for each team. - /** @var TeamScore[] $teamScores */ - $teamScores = []; - $teams = []; - foreach ($tied as $rankCache) { - $tiedteam = $rankCache->getTeam(); - $teamScores[$tiedteam->getTeamid()] = new TeamScore($tiedteam); - $teams[] = $tiedteam; - } - - // Get submission times for each of the teams. - /** @var ScoreCache[] $tiedScores */ - $tiedScores = $this->em->createQueryBuilder() - ->from(ScoreCache::class, 's') - ->join('s.problem', 'p') - ->join('p.contest_problems', 'cp', Join::WITH, 'cp.contest = :contest') - ->select('s') - ->andWhere('s.contest = :contest') - ->andWhere(sprintf('s.is_correct_%s = 1', $variant)) - ->andWhere('cp.allowSubmit = 1') - ->andWhere('s.team IN (:teams)') - ->setParameter('contest', $contest) - ->setParameter('teams', $teams) - ->getQuery() - ->getResult(); - - foreach ($tiedScores as $tiedScore) { - $teamScores[$tiedScore->getTeam()->getTeamid()]->solveTimes[] = - Utils::scoretime( - $tiedScore->getSolveTime($restricted), - (bool)$this->config->get('score_in_seconds') - ); - } - - // Now check for each team if it is ranked higher than $teamid. - foreach ($tied as $rankCache) { - $tiedteam = $rankCache->getTeam(); - if ($tiedteam->getTeamid() == $team->getTeamid()) { - continue; - } - if (Scoreboard::scoreTiebreaker($teamScores[$tiedteam->getTeamid()], - $teamScores[$team->getTeamid()]) < 0) { - $rank++; - } - } - } - } - return $rank; } @@ -548,10 +476,12 @@ public function updateRankCache(Contest $contest, Team $team): void $numPoints = []; $totalTime = []; $totalRuntime = []; + $timeOfLastCorrect = []; foreach ($variants as $variant => $isRestricted) { $numPoints[$variant] = 0; $totalTime[$variant] = $team->getPenalty(); $totalRuntime[$variant] = 0; + $timeOfLastCorrect[$variant] = 0; } $penaltyTime = (int) $this->config->get('penalty_time'); @@ -579,15 +509,25 @@ public function updateRankCache(Contest $contest, Team $team): void $penaltyTime, $scoreIsInSeconds); $numPoints[$variant] += $contestProblems[$probId]->getPoints(); - $totalTime[$variant] += Utils::scoretime( + $solveTimeForProblem = Utils::scoretime( (float)$scoreCacheCell->getSolveTime($isRestricted), $scoreIsInSeconds - ) + $penalty; + ); + $timeOfLastCorrect[$variant] = max($timeOfLastCorrect[$variant], $solveTimeForProblem); + $totalTime[$variant] += $solveTimeForProblem + $penalty; $totalRuntime[$variant] += $scoreCacheCell->getRuntime($isRestricted); } } } + foreach ($variants as $variant => $isRestricted) { + $scoreKey[$variant] = self::getICPCScoreKey( + $numPoints[$variant], + $totalTime[$variant], + $timeOfLastCorrect[$variant] + ); + } + // Use a direct REPLACE INTO query to drastically speed this up. $params = [ 'cid' => $contest->getCid(), @@ -598,11 +538,14 @@ public function updateRankCache(Contest $contest, Team $team): void 'pointsPublic' => $numPoints['public'], 'totalTimePublic' => $totalTime['public'], 'totalRuntimePublic' => $totalRuntime['public'], + 'sortKeyRestricted' => $scoreKey['restricted'], + 'sortKeyPublic' => $scoreKey['public'], ]; $this->em->getConnection()->executeQuery('REPLACE INTO rankcache (cid, teamid, points_restricted, totaltime_restricted, totalruntime_restricted, - points_public, totaltime_public, totalruntime_public) - VALUES (:cid, :teamid, :pointsRestricted, :totalTimeRestricted, :totalRuntimeRestricted, :pointsPublic, :totalTimePublic, :totalRuntimePublic)', $params); + points_public, totaltime_public, totalruntime_public, sort_key_restricted, sort_key_public) + VALUES (:cid, :teamid, :pointsRestricted, :totalTimeRestricted, :totalRuntimeRestricted, + :pointsPublic, :totalTimePublic, :totalRuntimePublic, :sortKeyRestricted, :sortKeyPublic)', $params); if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)', ['lock' => $lockString]) != 1) { @@ -610,6 +553,49 @@ public function updateRankCache(Contest $contest, Team $team): void } } + const SCALE = 9; + + // Converts integer or bcmath floats to a string that can be used as a key in a score cache. + // The resulting key will be a string with 33 characters, 23 before the decimal dot and 9 after. + // The keys are left-padded with zeros to ensure they have the same length and are lexicographically sortable. + // If the sort order is descending, the keys are inverted by subtracting them from a large number. + // Assumes that the input is a non-negative number, smaller than 10^23. + // + // Example: + // input: 42 + // output: "00000000000000000000042.000000000" + public static function convertToScoreKeyElement(int|string $value, Order $order = Order::Descending): string + { + // Ensure we have a fixed precision number with 9 decimals. + $value = bcadd("$value", "0", scale: self::SCALE); + + $ALMOST_INFINITE = "99999999999999999999999"; + if (bccomp($value, $ALMOST_INFINITE, scale: self::SCALE) > 0) { + throw new Exception("Value $value is too large to convert to a score key element."); + } + if (str_starts_with($value, '-')) { + throw new Exception("No negative values allowed in score key element, got $value."); + } + + // If ascending, we need to subtract it from a large high value. + if ($order === Order::Ascending) { + $value = bcsub($ALMOST_INFINITE, $value, scale: self::SCALE); + } + + // Left pad it so it has always the same number of characters. + return str_pad($value, 33, "0", STR_PAD_LEFT); + } + + public static function getICPCScoreKey(int $numSolved, int $totalTime, int $timeOfLastSolved): string + { + $scoreKeyArray = [ + self::convertToScoreKeyElement($numSolved), + self::convertToScoreKeyElement($totalTime, Order::Ascending), + self::convertToScoreKeyElement($timeOfLastSolved, Order::Ascending), + ]; + return implode(',', $scoreKeyArray); + } + /** * Recalculate the scoreCache and rankCache of a contest. * @@ -955,17 +941,19 @@ public function getScoreboardTwigData( } /** - * Get the teams to display on the scoreboard. + * Get the teams to display on the scoreboard, returns them in order. * @return Team[] */ - protected function getTeams(Contest $contest, bool $jury = false, ?Filter $filter = null): array + protected function getTeamsInOrder(Contest $contest, bool $jury = false, ?Filter $filter = null): array { $queryBuilder = $this->em->createQueryBuilder() ->from(Team::class, 't', 't.teamid') ->innerJoin('t.category', 'tc') + ->leftJoin(RankCache::class, 'r', Join::WITH, 'r.team = t AND r.contest = :rcid') ->leftJoin('t.affiliation', 'ta') - ->select('t, tc, ta') - ->andWhere('t.enabled = 1'); + ->select('t, tc, ta', 'COALESCE(t.display_name, t.name) AS HIDDEN effectivename') + ->andWhere('t.enabled = 1') + ->setParameter('rcid', $contest->getCid()); if (!$contest->isOpenToAllTeams()) { $queryBuilder @@ -1015,7 +1003,12 @@ protected function getTeams(Contest $contest, bool $jury = false, ?Filter $filte } } - return $queryBuilder->getQuery()->getResult(); + $ret = $queryBuilder + ->addOrderBy('tc.sortorder') + ->addOrderBy('r.sortKey' . ($jury ? 'Restricted' : 'Public'), 'DESC') + ->addOrderBy('effectivename') + ->getQuery()->getResult(); + return $ret; } /** @@ -1102,17 +1095,22 @@ protected function getScorecache(Contest $contest, ?Team $team = null): array /** * Get the rank cache for the given team. * @throws NonUniqueResultException + * @return RankCache[] */ - protected function getRankcache(Contest $contest, Team $team): ?RankCache + protected function getRankcache(Contest $contest, ?Team $team = null): array { $queryBuilder = $this->em->createQueryBuilder() ->from(RankCache::class, 'r') ->select('r') ->andWhere('r.contest = :contest') - ->andWhere('r.team = :team') - ->setParameter('contest', $contest) - ->setParameter('team', $team); + ->setParameter('contest', $contest); + + if ($team !== null) { + $queryBuilder + ->andWhere('r.team = :team') + ->setParameter('team', $team); + } - return $queryBuilder->getQuery()->getOneOrNullResult(); + return $queryBuilder->getQuery()->getResult(); } } diff --git a/webapp/src/Utils/Scoreboard/Scoreboard.php b/webapp/src/Utils/Scoreboard/Scoreboard.php index fbce9b02d6..b0e53fce1b 100644 --- a/webapp/src/Utils/Scoreboard/Scoreboard.php +++ b/webapp/src/Utils/Scoreboard/Scoreboard.php @@ -4,6 +4,7 @@ use App\Entity\Contest; use App\Entity\ContestProblem; +use App\Entity\RankCache; use App\Entity\ScoreCache; use App\Entity\Team; use App\Entity\TeamCategory; @@ -26,21 +27,22 @@ class Scoreboard protected ?array $bestInCategoryData = null; /** - * @param Team[] $teams + * @param Team[] $teamsInDescendingOrder * @param TeamCategory[] $categories * @param ContestProblem[] $problems * @param ScoreCache[] $scoreCache */ public function __construct( - protected readonly Contest $contest, - protected readonly array $teams, - protected readonly array $categories, - protected readonly array $problems, - protected readonly array $scoreCache, + protected readonly Contest $contest, + protected readonly array $teamsInDescendingOrder, + protected readonly array $categories, + protected readonly array $problems, + protected readonly array $scoreCache, + protected readonly array $rankCache, protected readonly FreezeData $freezeData, - bool $jury, - protected readonly int $penaltyTime, - protected readonly bool $scoreIsInSeconds + bool $jury, + protected readonly int $penaltyTime, + protected readonly bool $scoreIsInSeconds ) { $this->restricted = $jury || $freezeData->showFinal($jury); @@ -59,9 +61,9 @@ public function hasRestrictedAccess(): bool /** * @return Team[] */ - public function getTeams(): array + public function getTeamsInDescendingOrder(): array { - return $this->teams; + return $this->teamsInDescendingOrder; } /** @@ -122,10 +124,16 @@ protected function initializeScoreboard(): void // Initialize summary $this->summary = new Summary($this->problems); + $teamToRankCache = []; + foreach ($this->rankCache as $rc) { + $teamToRankCache[$rc->getTeam()->getTeamid()] = $rc; + } + // Initialize scores $this->scores = []; - foreach ($this->teams as $team) { - $this->scores[$team->getTeamid()] = new TeamScore($team); + foreach ($this->teamsInDescendingOrder as $team) { + $rankCacheForTeam = $teamToRankCache[$team->getTeamid()] ?? null; + $this->scores[$team->getTeamid()] = new TeamScore($team, $rankCacheForTeam, $this->restricted); } } @@ -139,8 +147,8 @@ protected function calculateScoreboard(): void foreach ($this->scoreCache as $scoreCell) { $teamId = $scoreCell->getTeam()->getTeamid(); $probId = $scoreCell->getProblem()->getProbid(); - // Skip this row if the team or problem is not known by us. - if (!array_key_exists($teamId, $this->teams) || + // Skip this cell if the team or problem is not known by us. + if (!array_key_exists($teamId, $this->teamsInDescendingOrder) || !array_key_exists($probId, $this->problems)) { continue; } @@ -161,21 +169,8 @@ protected function calculateScoreboard(): void runtime: $scoreCell->getRuntime($this->restricted), numSubmissionsInFreeze: $scoreCell->getPending(false), ); - - if ($scoreCell->getIsCorrect($this->restricted)) { - $solveTime = Utils::scoretime($scoreCell->getSolveTime($this->restricted), - $this->scoreIsInSeconds); - $contestProblem = $this->problems[$scoreCell->getProblem()->getProbid()]; - $this->scores[$teamId]->numPoints += $contestProblem->getPoints(); - $this->scores[$teamId]->solveTimes[] = $solveTime; - $this->scores[$teamId]->totalTime += $solveTime + $penalty; - $this->scores[$teamId]->totalRuntime += $scoreCell->getRuntime($this->restricted); - } } - // Now sort the scores using the scoreboard sort function. - uasort($this->scores, $this->scoreboardCompare(...)); - // Loop over all teams to calculate ranks and totals. $prevSortOrder = -1; $rank = 0; @@ -193,7 +188,7 @@ protected function calculateScoreboard(): void // Use previous team rank when scores are equal. if (isset($previousTeamId) && - $this->scoreCompare($this->scores[$previousTeamId], $teamScore) == 0) { + $this->scores[$previousTeamId]->getSortKey($this->restricted) === $teamScore->getSortKey($this->restricted)) { $teamScore->rank = $this->scores[$previousTeamId]->rank; } else { $teamScore->rank = $rank; @@ -247,93 +242,6 @@ protected function calculateScoreboard(): void } } - /** - * Scoreboard sorting function. It uses the following - * criteria: - * - First, use the sortorder override from the team_category table - * (e.g. score regular contestants always over spectators); - * - Then, use the scoreCompare function to determine the actual ordering - * based on number of problems solved and the time it took; - * - If still equal, order on team name alphabetically. - */ - protected function scoreboardCompare(TeamScore $a, TeamScore $b): int - { - // First order by our predefined sortorder based on category. - $a_sortorder = $a->team->getCategory()->getSortorder(); - $b_sortorder = $b->team->getCategory()->getSortorder(); - if ($a_sortorder != $b_sortorder) { - return $a_sortorder <=> $b_sortorder; - } - - // Then compare scores. - $scoreCompare = $this->scoreCompare($a, $b); - if ($scoreCompare != 0) { - return $scoreCompare; - } - - // Else, order by teamname alphabetically. - if ($a->team->getEffectiveName() != $b->team->getEffectiveName()) { - $collator = new Collator('en'); - return $collator->compare($a->team->getEffectiveName(), $b->team->getEffectiveName()); - } - // Undecided, should never happen in practice. - return 0; - } - - /** - * Main score comparison function, called from the 'scoreboardCompare' wrapper - * above. Scores based on the following criteria: - * - highest points from correct solutions; - * - least amount of total time spent on these solutions; (or lowest total runtime) - * - the tie-breaker function below. - */ - protected function scoreCompare(TeamScore $a, TeamScore $b): int - { - // More correctness points than someone else means higher rank. - if ($a->numPoints != $b->numPoints) { - return $b->numPoints <=> $a->numPoints; - } - // Else, less time spent means higher rank. - if ($this->getRuntimeAsScoreTiebreaker()) { // runtime ordering - if ($a->totalRuntime != $b->totalRuntime) { - return $a->totalRuntime <=> $b->totalRuntime; - } - } else { // solvetime ordering - if ($a->totalTime != $b->totalTime) { - return $a->totalTime <=> $b->totalTime; - } - } - // Else tie-breaker rule. - return static::scoreTiebreaker($a, $b); - } - - /** - * Tie-breaker comparison function, called from the 'scoreCompare' function - * above. Scores based on the following criterion: - * - fastest submission time for latest correct problem - */ - public static function scoreTiebreaker(TeamScore $a, TeamScore $b): int - { - $atimes = $a->solveTimes; - $btimes = $b->solveTimes; - rsort($atimes); - rsort($btimes); - - if (isset($atimes[0]) && isset($btimes[0])) { - return $atimes[0] <=> $btimes[0]; - } - if (!isset($atimes[0]) && !isset($btimes[0])) { - return 0; - } - if (!isset($atimes[0])) { - return -1; - } - if (!isset($btimes[0])) { - return 1; - } - - throw new Exception('Unhandled tie breaker case.'); - } /** * Return whether to show points for this scoreboard. diff --git a/webapp/src/Utils/Scoreboard/SingleTeamScoreboard.php b/webapp/src/Utils/Scoreboard/SingleTeamScoreboard.php index 3018141aef..4a8bc76ab4 100644 --- a/webapp/src/Utils/Scoreboard/SingleTeamScoreboard.php +++ b/webapp/src/Utils/Scoreboard/SingleTeamScoreboard.php @@ -27,7 +27,7 @@ public function __construct( protected readonly Team $team, protected readonly int $teamRank, array $problems, - protected readonly ?RankCache $rankCache, + array $rankCache, array $scoreCache, FreezeData $freezeData, bool $showFtsInFreeze, @@ -35,17 +35,22 @@ public function __construct( bool $scoreIsInSeconds ) { $this->showRestrictedFts = $showFtsInFreeze || $freezeData->showFinal(); - parent::__construct($contest, [$team->getTeamid() => $team], [], $problems, $scoreCache, $freezeData, true, + parent::__construct($contest, [$team->getTeamid() => $team], [], $problems, $scoreCache, $rankCache, $freezeData, true, $penaltyTime, $scoreIsInSeconds); } protected function calculateScoreboard(): void { + $rankCacheForTeam = null; + if ($this->rankCache !== null && count($this->rankCache) > 0) { + $rankCacheForTeam = $this->rankCache[0]; + } + $teamScore = $this->scores[$this->team->getTeamid()]; - if ($this->rankCache !== null) { - $teamScore->numPoints += $this->rankCache->getPointsRestricted(); - $teamScore->totalTime += $this->rankCache->getTotaltimeRestricted(); - $teamScore->totalRuntime += $this->rankCache->getTotalruntimeRestricted(); + if ($rankCacheForTeam !== null) { + $teamScore->numPoints += $rankCacheForTeam->getPointsRestricted(); + $teamScore->totalTime += $rankCacheForTeam->getTotaltimeRestricted(); + $teamScore->totalRuntime += $rankCacheForTeam->getTotalruntimeRestricted(); } $teamScore->rank = $this->teamRank; diff --git a/webapp/src/Utils/Scoreboard/TeamScore.php b/webapp/src/Utils/Scoreboard/TeamScore.php index f25805a75c..2d9591d85f 100644 --- a/webapp/src/Utils/Scoreboard/TeamScore.php +++ b/webapp/src/Utils/Scoreboard/TeamScore.php @@ -2,20 +2,39 @@ namespace App\Utils\Scoreboard; +use App\Entity\RankCache; use App\Entity\Team; class TeamScore { public int $numPoints = 0; - /** @var float[] */ - public array $solveTimes = []; public int $rank = 0; public int $totalTime; public int $totalRuntime = 0; - public function __construct(public Team $team) + public function __construct(public Team $team, public ?RankCache $rankCache, bool $restricted) { $this->totalTime = $team->getPenalty(); + if ($this->rankCache) { + if ($restricted) { + $this->numPoints = $rankCache->getPointsRestricted(); + $this->totalTime += $rankCache->getTotaltimeRestricted(); + $this->totalRuntime = $rankCache->getTotalruntimeRestricted(); + } else { + $this->numPoints = $rankCache->getPointsPublic(); + $this->totalTime += $rankCache->getTotaltimePublic(); + $this->totalRuntime = $rankCache->getTotalruntimePublic(); + } + } + } + + public function getSortKey(bool $restricted): string + { + if ($this->rankCache === null) { + // Sorts teams without a rank last. + return '.'; + } + return $restricted ? $this->rankCache->getSortKeyRestricted() : $this->rankCache->getSortKeyPublic(); } } diff --git a/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php b/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php index 7833e99abe..8ff63433d4 100644 --- a/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php +++ b/webapp/tests/Unit/Integration/ScoreboardIntegrationTest.php @@ -411,7 +411,7 @@ protected function assertFTSMatch(array $expected_fts, Scoreboard $scoreboard): $matrix = $scoreboard->getMatrix(); $teams = []; $probs = []; - foreach ($scoreboard->getTeams() as $team) { + foreach ($scoreboard->getTeamsInDescendingOrder() as $team) { $teams[$team->getTeamid()] = $team; } foreach ($scoreboard->getProblems() as $prob) { diff --git a/webapp/tests/Unit/Service/AwardServiceTest.php b/webapp/tests/Unit/Service/AwardServiceTest.php index 4db426ad21..b4a33fd7e9 100644 --- a/webapp/tests/Unit/Service/AwardServiceTest.php +++ b/webapp/tests/Unit/Service/AwardServiceTest.php @@ -6,12 +6,15 @@ use App\Entity\Contest; use App\Entity\ContestProblem; use App\Entity\Problem; +use App\Entity\RankCache; use App\Entity\ScoreCache; use App\Entity\Team; use App\Entity\TeamCategory; use App\Service\AwardService; use App\Service\EventLogService; +use App\Service\ScoreboardService; use App\Utils\Scoreboard\Scoreboard; +use Doctrine\Common\Collections\Order; use ReflectionClass; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -142,7 +145,11 @@ protected function setUp(): void ], ]; $scoreCache = []; + $rankCache = []; foreach ($scores as $teamLabel => $scoresForTeam) { + $sumMinutes = 0; + $numSolved = 0; + $lastCorrect = 0; foreach ($scoresForTeam as $problemLabel => $minute) { $firstToSolve = in_array( $teamLabel . $problemLabel, @@ -156,7 +163,22 @@ protected function setUp(): void ->setSolvetimeRestricted(60 * $minute) ->setIsCorrectRestricted(true) ->setIsFirstToSolve($firstToSolve); + $sumMinutes += $minute; + $numSolved++; + $lastCorrect = max($lastCorrect, $minute); } + $sortKey = + implode(',', [ + ScoreboardService::convertToScoreKeyElement($numSolved), + ScoreboardService::convertToScoreKeyElement($sumMinutes, Order::Ascending), + ScoreboardService::convertToScoreKeyElement($lastCorrect, Order::Ascending), + ]); + $rankCache[] = (new RankCache()) + ->setContest($this->contest) + ->setTeam($teams[ord($teamLabel) - ord('A')]) + ->setPointsRestricted($numSolved) + ->setTotaltimeRestricted(60 * $sumMinutes) + ->setSortKeyRestricted($sortKey); } $this->scoreboard = new Scoreboard( @@ -165,6 +187,7 @@ protected function setUp(): void $categories, $problems, $scoreCache, + $rankCache, $this->contest->getFreezeData(), true, 20, @@ -252,7 +275,7 @@ public function testFirstToSolve(): void public function testMedalType(int $teamIndex, ?string $expectedMedalType): void { $awardsService = $this->getAwardService(); - $team = $this->scoreboard->getTeams()[$teamIndex]; + $team = $this->scoreboard->getTeamsInDescendingOrder()[$teamIndex]; static::assertEquals($expectedMedalType, $awardsService->medalType($team, $this->contest, $this->scoreboard)); } diff --git a/webapp/tests/Unit/Service/ScoreboardServiceTest.php b/webapp/tests/Unit/Service/ScoreboardServiceTest.php new file mode 100644 index 0000000000..bc3087657b --- /dev/null +++ b/webapp/tests/Unit/Service/ScoreboardServiceTest.php @@ -0,0 +1,155 @@ +expectException(Exception::class); + ScoreboardService::convertToScoreKeyElement(-1); + } + + public function testScoreKeyNegativeBcmath(): void + { + $this->expectException(Exception::class); + ScoreboardService::convertToScoreKeyElement("-0.123"); + } + + public function testTooLarge(): void + { + $this->expectException(Exception::class); + ScoreboardService::convertToScoreKeyElement("100000000000000000000000"); + } + + public function testEmptyTeams(): void + { + $teamA = ScoreboardService::getICPCScoreKey(0, 0, 0); + $teamB = ScoreboardService::getICPCScoreKey(0, 0, 0); + self::assertEquals($teamA, $teamB); + } + + public function testEqualTeams(): void + { + $teamA = ScoreboardService::getICPCScoreKey(7, 666, 420); + $teamB = ScoreboardService::getICPCScoreKey(7, 666, 420); + self::assertEquals($teamA, $teamB); + } + + public function testOneTeamEmpty(): void + { + $teamA = ScoreboardService::getICPCScoreKey(0, 0, 0); + $teamB = ScoreboardService::getICPCScoreKey(7, 666, 420); + self::assertTrue($teamA < $teamB); + } + + public function testOneTeamSolvedMore(): void + { + $teamA = ScoreboardService::getICPCScoreKey(1, 333, 210); + $teamB = ScoreboardService::getICPCScoreKey(7, 666, 420); + self::assertTrue($teamA < $teamB); + } + + public function testEqualExceptLast(): void + { + $teamA = ScoreboardService::getICPCScoreKey(7, 666, 420); + $teamB = ScoreboardService::getICPCScoreKey(7, 666, 421); + self::assertTrue($teamA > $teamB); + } + + public function testEqualExceptSecondLast(): void + { + $teamA = ScoreboardService::getICPCScoreKey(7, 666, 420); + $teamB = ScoreboardService::getICPCScoreKey(7, 667, 420); + self::assertTrue($teamA > $teamB); + } + + public function testEqualExceptFirst(): void + { + $teamA = ScoreboardService::getICPCScoreKey(7, 666, 420); + $teamB = ScoreboardService::getICPCScoreKey(8, 666, 420); + self::assertTrue($teamA < $teamB); + } +} diff --git a/webapp/tests/Unit/Utils/Scoreboard/ScoreboardTest.php b/webapp/tests/Unit/Utils/Scoreboard/ScoreboardTest.php index ff636b0c6e..7496541271 100644 --- a/webapp/tests/Unit/Utils/Scoreboard/ScoreboardTest.php +++ b/webapp/tests/Unit/Utils/Scoreboard/ScoreboardTest.php @@ -14,109 +14,6 @@ class ScoreboardTest extends BaseBaseTestCase { - /** - * Test that the scoreboard tiebreaker works with two teams without any scores. - */ - public function testScoreTiebreakerEmptyTeams(): void - { - $teamA = new Team(); - $teamB = new Team(); - $scoreA = new TeamScore($teamA); - $scoreB = new TeamScore($teamB); - - // Always test in both directions for symmetry. - $tie = Scoreboard::scoreTieBreaker($scoreA, $scoreB); - self::assertEquals(0, $tie); - $tie = Scoreboard::scoreTieBreaker($scoreB, $scoreA); - self::assertEquals(0, $tie); - } - - /** - * Test that the scoreboard tiebreaker works with two teams with equal scores. - */ - public function testScoreTiebreakerEqualTeams(): void - { - $teamA = new Team(); - $teamB = new Team(); - $scoreA = new TeamScore($teamA); - foreach ([6, 367, 2, 100] as $time) { - $scoreA->solveTimes[] = $time; - } - $scoreB = new TeamScore($teamB); - foreach ([100, 6, 2, 367] as $time) { - $scoreB->solveTimes[] = $time; - } - - $tie = Scoreboard::scoreTieBreaker($scoreA, $scoreB); - self::assertEquals(0, $tie); - $tie = Scoreboard::scoreTieBreaker($scoreB, $scoreA); - self::assertEquals(0, $tie); - } - - /** - * Test that the scoreboard tiebreaker works if only one team has scores. - */ - public function testScoreTiebreakerOneTeamEmpty(): void - { - $teamA = new Team(); - $teamB = new Team(); - $scoreA = new TeamScore($teamA); - foreach ([6, 367, 2, 100] as $time) { - $scoreA->solveTimes[] = $time; - } - $scoreB = new TeamScore($teamB); - - $tie = Scoreboard::scoreTieBreaker($scoreA, $scoreB); - self::assertEquals(1, $tie); - $tie = Scoreboard::scoreTieBreaker($scoreB, $scoreA); - self::assertEquals(-1, $tie); - } - - - /** - * Test that the scoreboard tiebreaker works if both teams have the same highest score. - */ - public function testScoreTiebreakerHighestEqual(): void - { - $teamA = new Team(); - $teamB = new Team(); - $scoreA = new TeamScore($teamA); - foreach ([6, 367, 2, 100] as $time) { - $scoreA->solveTimes[] = $time; - } - $scoreB = new TeamScore($teamB); - foreach ([23, 150, 367] as $time) { - $scoreB->solveTimes[] = $time; - } - - $tie = Scoreboard::scoreTieBreaker($scoreA, $scoreB); - self::assertEquals(0, $tie); - $tie = Scoreboard::scoreTieBreaker($scoreB, $scoreA); - self::assertEquals(0, $tie); - } - - /** - * Test that the scoreboard tiebreaker works if scores are different. - */ - public function testScoreTiebreakerUnequal(): void - { - $teamA = new Team(); - $teamB = new Team(); - $scoreA = new TeamScore($teamA); - foreach ([6, 367, 2, 100] as $time) { - $scoreA->solveTimes[] = $time; - } - $scoreB = new TeamScore($teamB); - foreach ([23, 150, 2] as $time) { - $scoreB->solveTimes[] = $time; - } - - $tie = Scoreboard::scoreTieBreaker($scoreA, $scoreB); - self::assertEquals(1, $tie); - $tie = Scoreboard::scoreTieBreaker($scoreB, $scoreA); - self::assertEquals(-1, $tie); - } - /** * @dataProvider provideFreezeDataProgress */ @@ -134,7 +31,7 @@ public function testScoreboardProgress( $em = self::getContainer()->get('doctrine')->getManager(); $contest = $em->getRepository(Contest::class)->findOneBy(['name' => $reference]); $freezeData = new FreezeData($contest); - $scoreBoard = new Scoreboard($contest, [], [], [], [], $freezeData, false, 0, true); + $scoreBoard = new Scoreboard($contest, [], [], [], [], [], $freezeData, false, 0, true); self::assertEquals($scoreBoard->getProgress(), $progress); }