diff --git a/webapp/migrations/Version20231229094359.php b/webapp/migrations/Version20231229094359.php new file mode 100644 index 0000000000..0d2ebfba4d --- /dev/null +++ b/webapp/migrations/Version20231229094359.php @@ -0,0 +1,36 @@ +addSql('ALTER TABLE scorecache ADD is_potential_first_to_solve TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Is this potentially the first solution to this problem?\' AFTER is_first_to_solve'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE scorecache DROP is_potential_first_to_solve'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/DataFixtures/Test/SampleTeamsFixture.php b/webapp/src/DataFixtures/Test/SampleTeamsFixture.php new file mode 100644 index 0000000000..3b82793c8f --- /dev/null +++ b/webapp/src/DataFixtures/Test/SampleTeamsFixture.php @@ -0,0 +1,45 @@ +getRepository(TeamAffiliation::class)->findOneBy(['externalid' => 'utrecht']); + $category = $manager->getRepository(TeamCategory::class)->findOneBy(['externalid' => 'participants']); + + $team1 = new Team(); + $team1 + ->setExternalid('team1') + ->setIcpcid('team1') + ->setLabel('team1') + ->setName('Team 1') + ->setAffiliation($affiliation) + ->setCategory($category); + + $team2 = new Team(); + $team2 + ->setExternalid('team2') + ->setIcpcid('team2') + ->setLabel('team2') + ->setName('Team 2') + ->setAffiliation($affiliation) + ->setCategory($category); + + $manager->persist($team1); + $manager->persist($team2); + $manager->flush(); + + $this->addReference(self::FIRST_TEAM_REFERENCE, $team1); + $this->addReference(self::SECOND_TEAM_REFERENCE, $team2); + } +} diff --git a/webapp/src/Entity/ScoreCache.php b/webapp/src/Entity/ScoreCache.php index 67aa8bcf0b..df8d7acb92 100644 --- a/webapp/src/Entity/ScoreCache.php +++ b/webapp/src/Entity/ScoreCache.php @@ -94,6 +94,12 @@ class ScoreCache ])] private bool $is_first_to_solve = false; + #[ORM\Column(options: [ + 'comment' => 'Is this potentially the first solution to this problem?', + 'default' => 0, + ])] + private bool $is_potential_first_to_solve = false; + #[ORM\Id] #[ORM\ManyToOne] #[ORM\JoinColumn(name: 'cid', referencedColumnName: 'cid', onDelete: 'CASCADE')] @@ -230,6 +236,17 @@ public function getIsFirstToSolve() : bool return $this->is_first_to_solve; } + public function setIsPotentialFirstToSolve(bool $isPotentialFirstToSolve): ScoreCache + { + $this->is_potential_first_to_solve = $isPotentialFirstToSolve; + return $this; + } + + public function getIsPotentialFirstToSolve() : bool + { + return $this->is_potential_first_to_solve; + } + public function setContest(?Contest $contest = null): ScoreCache { $this->contest = $contest; diff --git a/webapp/src/Service/ScoreboardService.php b/webapp/src/Service/ScoreboardService.php index 4bad3a1db8..e54f7516e9 100644 --- a/webapp/src/Service/ScoreboardService.php +++ b/webapp/src/Service/ScoreboardService.php @@ -254,7 +254,8 @@ public function calculateScoreRow( Contest $contest, Team $team, Problem $problem, - bool $updateRankCache = true + bool $updateRankCache = true, + bool $updatePotentialFirstToSolves = true ): void { $this->logger->debug( "ScoreboardService::calculateScoreRow '%d' '%d' '%d'", @@ -417,6 +418,7 @@ public function calculateScoreRow( // See if this submission was the first to solve this problem. // Only relevant if it was correct in the first place. $firstToSolve = false; + $potentialFirstToSolve = false; if ($correctJury) { $params = [ 'cid' => $contest->getCid(), @@ -437,32 +439,49 @@ public function calculateScoreRow( // - or already judged to be correct (if it is judged but not correct, // it is not a first to solve) // - or the submission is still queued for judgement (judgehost is NULL). - $verificationRequiredExtra = $verificationRequired ? 'OR j.verified = 0' : ''; + // If there are no valid correct submissions submitted earlier but there + // are submissions awaiting judgement, we are potentially the first to solve. + // We need to keep track of this since we later need to set the actual first to solve. + $verificationRequiredExtra = ($verificationRequired && !$useExternalJudgements) ? 'OR j.verified = 0' : ''; if ($useExternalJudgements) { - $firstToSolve = 0 == $this->em->getConnection()->fetchOne(' + $baseQuery = ' SELECT count(*) FROM submission s LEFT JOIN external_judgement ej USING (submitid) LEFT JOIN external_judgement ej2 ON ej2.submitid = s.submitid AND ej2.starttime > ej.starttime LEFT JOIN team t USING(teamid) LEFT JOIN team_category tc USING (categoryid) WHERE s.valid = 1 AND - (ej.result IS NULL OR ej.result = :correctResult '. - $verificationRequiredExtra.') AND s.cid = :cid AND s.probid = :probid AND tc.sortorder = :teamSortOrder AND - round(s.submittime,4) < :submitTime', $params); + round(s.submittime,4) < :submitTime'; + $judgingTable = 'ej'; } else { - $firstToSolve = 0 == $this->em->getConnection()->fetchOne(' + $baseQuery = ' SELECT count(*) FROM submission s LEFT JOIN judging j ON (s.submitid=j.submitid AND j.valid=1) LEFT JOIN team t USING (teamid) LEFT JOIN team_category tc USING (categoryid) WHERE s.valid = 1 AND - (j.judgingid IS NULL OR j.result IS NULL OR j.result = :correctResult '. - $verificationRequiredExtra.') AND s.cid = :cid AND s.probid = :probid AND tc.sortorder = :teamSortOrder AND - round(s.submittime,4) < :submitTime', $params); + round(s.submittime,4) < :submitTime'; + $judgingTable = 'j'; + } + + $numEarlierCorrect = $this->em->getConnection()->fetchOne( + "$baseQuery AND $judgingTable.result = :correctResult", + $params); + $numEarlierPending = $this->em->getConnection()->fetchOne( + "$baseQuery AND ($judgingTable.result IS NULL $verificationRequiredExtra)", + $params); + + if ($numEarlierCorrect == 0) { + // This is either a first to solve or potential first to solve + if ($numEarlierPending == 0) { + $firstToSolve = true; + } else { + $potentialFirstToSolve = true; + } } } @@ -482,13 +501,14 @@ public function calculateScoreRow( 'runtimePublic' => $runtimePubl === PHP_INT_MAX ? 0 : $runtimePubl, 'isCorrectPublic' => (int)$correctPubl, 'isFirstToSolve' => (int)$firstToSolve, + 'isPotentialFirstToSolve' => (int)$potentialFirstToSolve, ]; $this->em->getConnection()->executeQuery('REPLACE INTO scorecache (cid, teamid, probid, submissions_restricted, pending_restricted, solvetime_restricted, runtime_restricted, is_correct_restricted, - submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve) + submissions_public, pending_public, solvetime_public, runtime_public, is_correct_public, is_first_to_solve, is_potential_first_to_solve) VALUES (:cid, :teamid, :probid, :submissionsRestricted, :pendingRestricted, :solvetimeRestricted, :runtimeRestricted, :isCorrectRestricted, - :submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve)', $params); + :submissionsPublic, :pendingPublic, :solvetimePublic, :runtimePublic, :isCorrectPublic, :isFirstToSolve, :isPotentialFirstToSolve)', $params); if ($this->em->getConnection()->fetchOne('SELECT RELEASE_LOCK(:lock)', ['lock' => $lockString]) != 1) { @@ -499,6 +519,31 @@ public function calculateScoreRow( if ($updateRankCache && ($correctJury || $correctPubl)) { $this->updateRankCache($contest, $team); } + + // If we did not have a first to solve, we need to check if we have any + // potential first to solve that are now the first to solve. + // We only do this if there are no pending submissions for this problem + // for this team and if this submission is not a potential first to solve itself. + // We also do this only once, to not have infinite loops if we have multiple + // potential first to solves. + if ($updatePotentialFirstToSolves && $pendingJury === 0 && !$potentialFirstToSolve) { + /** @var ScoreCache[] $potentialFirstToSolves */ + $potentialFirstToSolves = $this->em->createQueryBuilder() + ->from(ScoreCache::class, 's') + ->join('s.team', 't') + ->select('s', 't') + ->andWhere('s.contest = :contest') + ->andWhere('s.problem = :problem') + ->andWhere('s.is_potential_first_to_solve = 1') + ->setParameter('contest', $contest) + ->setParameter('problem', $problem) + ->getQuery() + ->getResult(); + + foreach ($potentialFirstToSolves as $row) { + $this->calculateScoreRow($contest, $row->getTeam(), $problem, $updateRankCache, false); + } + } } /** diff --git a/webapp/tests/Unit/Service/ScoreboardServiceTest.php b/webapp/tests/Unit/Service/ScoreboardServiceTest.php new file mode 100644 index 0000000000..be00ac8313 --- /dev/null +++ b/webapp/tests/Unit/Service/ScoreboardServiceTest.php @@ -0,0 +1,117 @@ +get(EntityManagerInterface::class); + $this->loadFixture(SampleTeamsFixture::class); + $team1 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::FIRST_TEAM_REFERENCE); + $team2 = $this->fixtureExecutor->getReferenceRepository()->getReference(SampleTeamsFixture::SECOND_TEAM_REFERENCE); + $contest = $em->getRepository(Contest::class)->findOneBy(['shortname' => 'demo']); + $contestProblem = $contest->getProblems()->first(); + $problem = $contestProblem->getProblem(); + + $scoreboardService = static::getContainer()->get(ScoreboardService::class); + + // Create a submission for both team 1 and team 2. + // The submission for team 1 will be pending, while the submission + // for team 2 will be correct. + $submission1 = $this->createSubmission($em, $team1, $contestProblem, $contest, null); + $submission2 = $this->createSubmission($em, $team2, $contestProblem, $contest, 'correct'); + $em->flush(); + + $scoreboardService->calculateScoreRow($contest, $team1, $problem); + $scoreboardService->calculateScoreRow($contest, $team2, $problem); + + /** @var ScoreCache $scoreCacheTeam1 */ + $scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([ + 'contest' => $contest, + 'team' => $team1, + 'problem' => $problem, + ]); + /** @var ScoreCache $scoreCacheTeam2 */ + $scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([ + 'contest' => $contest, + 'team' => $team2, + 'problem' => $problem, + ]); + + static::assertFalse($scoreCacheTeam1->getIsFirstToSolve()); + static::assertFalse($scoreCacheTeam2->getIsFirstToSolve()); + + // Now update the submission for team 1 to be wrong + $submission1->getJudgings()->first()->setResult('wrong'); + $em->flush(); + + $scoreboardService->calculateScoreRow($contest, $team1, $problem); + + // We need to clear the entity manager to make sure we get the updated score caches. + $em->clear(); + + /** @var ScoreCache $scoreCacheTeam1 */ + $scoreCacheTeam1 = $em->getRepository(ScoreCache::class)->findOneBy([ + 'contest' => $contest, + 'team' => $team1, + 'problem' => $problem, + ]); + /** @var ScoreCache $scoreCacheTeam2 */ + $scoreCacheTeam2 = $em->getRepository(ScoreCache::class)->findOneBy([ + 'contest' => $contest, + 'team' => $team2, + 'problem' => $problem, + ]); + + static::assertFalse($scoreCacheTeam1->getIsFirstToSolve()); + static::assertTrue($scoreCacheTeam2->getIsFirstToSolve()); + } + + protected function createSubmission( + EntityManagerInterface $em, + Team $team, + ContestProblem $problem, + Contest $contest, + ?string $result + ): Submission { + $submission = new Submission(); + $submission + ->setTeam($team) + ->setProblem($problem->getProblem()) + ->setContest($contest) + ->setContestProblem($problem) + ->setLanguage($em->getRepository(Language::class)->findOneBy(['name' => 'C++'])) + ->setValid(true) + ->setSubmittime(Utils::now()) + ->addJudging( + $judging = (new Judging()) + ->setValid(true) + ->setStarttime(Utils::now()) + ->setEndtime(Utils::now()) + ->setSubmission($submission) + ->setResult($result) + ); + + $em->persist($submission); + $em->persist($judging); + + return $submission; + } +}