From 400fcf782644bd39c549aa1bb85d3e866793c047 Mon Sep 17 00:00:00 2001 From: Tobias Werth Date: Sat, 29 Mar 2025 14:39:20 +0100 Subject: [PATCH] Allow selecting new problem types as part of the problem entity. While you can select the new types, they won't function yet. Part of https://github.com/DOMjudge/domjudge/issues/2525 Problem types are defined here: https://icpc.io/problem-package-format/spec/2023-07-draft.html#type --- webapp/migrations/Version20250323190305.php | 44 ++++++++ .../src/Controller/Jury/ContestController.php | 2 +- .../src/Controller/Jury/ProblemController.php | 22 +--- .../Controller/Jury/SubmissionController.php | 2 +- webapp/src/Entity/Problem.php | 103 ++++++++++++++---- webapp/src/Form/Type/ProblemType.php | 18 +-- webapp/src/Service/DOMJudgeService.php | 6 +- webapp/src/Service/ImportProblemService.php | 24 +++- webapp/templates/jury/problem.html.twig | 2 +- .../templates/partials/problem_list.html.twig | 5 +- .../Controller/Jury/ProblemControllerTest.php | 4 +- .../Controller/Team/ProblemControllerTest.php | 5 +- 12 files changed, 176 insertions(+), 61 deletions(-) create mode 100644 webapp/migrations/Version20250323190305.php diff --git a/webapp/migrations/Version20250323190305.php b/webapp/migrations/Version20250323190305.php new file mode 100644 index 0000000000..126980b53d --- /dev/null +++ b/webapp/migrations/Version20250323190305.php @@ -0,0 +1,44 @@ +addSql('ALTER TABLE problem ADD types INT NOT NULL COMMENT \'Bitset of problem types, default is pass-fail.\''); + $this->addSql('UPDATE problem SET types = 1'); + $this->addSql('UPDATE problem SET types = 5 WHERE is_multipass_problem = 1'); + $this->addSql('UPDATE problem SET types = 9 WHERE combined_run_compare = 1'); + $this->addSql('UPDATE problem SET types = 13 WHERE combined_run_compare = 1 AND is_multipass_problem = 1'); + $this->addSql('ALTER TABLE problem DROP combined_run_compare, DROP is_multipass_problem'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE problem ADD combined_run_compare TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Use the exit code of the run script to compute the verdict\', ADD is_multipass_problem TINYINT(1) DEFAULT 0 NOT NULL COMMENT \'Whether this problem is a multi-pass problem.\''); + $this->addSql('UPDATE problem SET combined_run_compare = 1 WHERE types = 9 OR types = 13'); + $this->addSql('UPDATE problem SET is_multipass_problem = 1 WHERE types = 5 OR types = 13'); + $this->addSql('ALTER TABLE problem DROP types'); + } + + public function isTransactional(): bool + { + return false; + } +} diff --git a/webapp/src/Controller/Jury/ContestController.php b/webapp/src/Controller/Jury/ContestController.php index 8e756bf899..84f9a2a729 100644 --- a/webapp/src/Controller/Jury/ContestController.php +++ b/webapp/src/Controller/Jury/ContestController.php @@ -714,7 +714,7 @@ public function prefetchAction(Request $request, int $contestId): Response $runConfig = Utils::jsonEncode( [ 'hash' => $runExec->getHash(), - 'combined_run_compare' => $problem->getCombinedRunCompare(), + 'combined_run_compare' => $problem->isInteractiveProblem(), ] ); $judgeTask = new JudgeTask(); diff --git a/webapp/src/Controller/Jury/ProblemController.php b/webapp/src/Controller/Jury/ProblemController.php index 28e9916724..8e78f047e1 100644 --- a/webapp/src/Controller/Jury/ProblemController.php +++ b/webapp/src/Controller/Jury/ProblemController.php @@ -204,17 +204,10 @@ public function indexAction(): Response $problemdata['badges'] = ['value' => $badges]; // merge in the rest of the data - $type = ''; - if ($p->getCombinedRunCompare()) { - $type .= ' interactive'; - } - if ($p->isMultipassProblem()) { - $type .= ' multi-pass (max passes: ' . $p->getMultipassLimit() . ')'; - } $problemdata = array_merge($problemdata, [ 'num_contests' => ['value' => (int)($contestCounts[$p->getProbid()] ?? 0)], 'num_testcases' => ['value' => (int)$row['testdatacount']], - 'type' => ['value' => $type], + 'type' => ['value' => $p->getTypesAsString()], ]); $data_to_add = [ @@ -304,7 +297,7 @@ public function exportAction(int $problemId): StreamedResponse $yaml = ['name' => $problem->getName()]; if (!empty($problem->getCompareExecutable())) { $yaml['validation'] = 'custom'; - } elseif ($problem->getCombinedRunCompare() && !empty($problem->getRunExecutable())) { + } elseif ($problem->isInteractiveProblem() && !empty($problem->getRunExecutable())) { $yaml['validation'] = 'custom interactive'; } @@ -340,7 +333,7 @@ public function exportAction(int $problemId): StreamedResponse $compareExecutable = null; if ($problem->getCompareExecutable()) { $compareExecutable = $problem->getCompareExecutable(); - } elseif ($problem->getCombinedRunCompare()) { + } elseif ($problem->isInteractiveProblem()) { $compareExecutable = $problem->getRunExecutable(); } if ($compareExecutable) { @@ -496,13 +489,6 @@ public function viewAction(Request $request, SubmissionService $submissionServic page: $request->query->getInt('page', 1), ); - $type = ''; - if ($problem->getCombinedRunCompare()) { - $type .= ' interactive'; - } - if ($problem->isMultipassProblem()) { - $type .= ' multi-pass (max passes: ' . $problem->getMultipassLimit() . ')'; - } $data = [ 'problem' => $problem, 'problemAttachmentForm' => $problemAttachmentForm->createView(), @@ -512,7 +498,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic 'defaultOutputLimit' => (int)$this->config->get('output_limit'), 'defaultRunExecutable' => (string)$this->config->get('default_run'), 'defaultCompareExecutable' => (string)$this->config->get('default_compare'), - 'type' => $type, + 'type' => $problem->getTypesAsString(), 'showContest' => count($this->dj->getCurrentContests(honorCookie: true)) > 1, 'showExternalResult' => $this->dj->shadowMode(), 'lockedProblem' => $lockedProblem, diff --git a/webapp/src/Controller/Jury/SubmissionController.php b/webapp/src/Controller/Jury/SubmissionController.php index b8cf48f021..0d6c551e12 100644 --- a/webapp/src/Controller/Jury/SubmissionController.php +++ b/webapp/src/Controller/Jury/SubmissionController.php @@ -577,7 +577,7 @@ public function viewAction( 'unjudgableReasons' => $unjudgableReasons, 'verificationRequired' => (bool)$this->config->get('verification_required'), 'claimWarning' => $claimWarning, - 'combinedRunCompare' => $submission->getProblem()->getCombinedRunCompare(), + 'combinedRunCompare' => $submission->getProblem()->isInteractiveProblem(), 'requestedOutputCount' => $requestedOutputCount, 'version_warnings' => [], 'isMultiPassProblem' => $submission->getProblem()->isMultipassProblem(), diff --git a/webapp/src/Entity/Problem.php b/webapp/src/Entity/Problem.php index c108c83180..3704fe85bc 100644 --- a/webapp/src/Entity/Problem.php +++ b/webapp/src/Entity/Problem.php @@ -86,13 +86,6 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?string $special_compare_args = null; - #[ORM\Column(options: [ - 'comment' => 'Use the exit code of the run script to compute the verdict', - 'default' => 0, - ])] - #[Serializer\Exclude] - private bool $combined_run_compare = false; - #[Assert\File] #[Serializer\Exclude] private ?UploadedFile $problemstatementFile = null; @@ -108,12 +101,24 @@ class Problem extends BaseApiEntity implements #[Serializer\Exclude] private ?string $problemstatement_type = null; - #[ORM\Column(options: [ - 'comment' => 'Whether this problem is a multi-pass problem.', - 'default' => 0, - ])] + // These types are encoded as bitset - if you add a new type, use the next power of 2. + public const TYPE_PASS_FAIL = 1; + public const TYPE_SCORING = 2; + public const TYPE_MULTI_PASS = 4; + public const TYPE_INTERACTIVE = 8; + public const TYPE_SUBMIT_ANSWER = 16; + + private array $typesToString = [ + self::TYPE_PASS_FAIL => 'pass-fail', + self::TYPE_SCORING => 'scoring', + self::TYPE_MULTI_PASS => 'multi-pass', + self::TYPE_INTERACTIVE => 'interactive', + self::TYPE_SUBMIT_ANSWER => 'submit-answer', + ]; + + #[ORM\Column(options: ['comment' => 'Bitmask of problem types, default is pass-fail.'])] #[Serializer\Exclude] - private bool $isMultipassProblem = false; + private int $types = self::TYPE_PASS_FAIL; #[ORM\Column( nullable: true, @@ -287,26 +292,84 @@ public function getSpecialCompareArgs(): ?string return $this->special_compare_args; } - public function setCombinedRunCompare(bool $combinedRunCompare): Problem + public function setTypesAsString(array $types): Problem { - $this->combined_run_compare = $combinedRunCompare; + $stringToTypes = array_flip($this->typesToString); + $typeConstants = []; + foreach ($types as $type) { + if (!isset($stringToTypes[$type])) { + throw new Exception("Unknown problem type: '$type', must be one of " . implode(', ', array_keys($stringToTypes))); + } + $typeConstants[$type] = $stringToTypes[$type]; + } + $this->setTypes($typeConstants); + return $this; } - public function getCombinedRunCompare(): bool + public function getTypesAsString(): string + { + $typeConstants = $this->getTypes(); + $typeStrings = []; + foreach ($typeConstants as $type) { + if (!isset($this->typesToString[$type])) { + throw new Exception("Unknown problem type: '$type'"); + } + $typeStrings[] = $this->typesToString[$type]; + } + return implode(', ', $typeStrings); + } + + public function getTypes(): array { - return $this->combined_run_compare; + $ret = []; + foreach (array_keys($this->typesToString) as $type) { + if ($this->types & $type) { + $ret[] = $type; + } + } + return $ret; } - public function setMultipassProblem(bool $isMultipassProblem): Problem + public function setTypes(array $types): Problem { - $this->isMultipassProblem = $isMultipassProblem; + $types = array_unique($types); + $this->types = 0; + foreach ($types as $type) { + $this->types |= $type; + } + if (!($this->types & self::TYPE_PASS_FAIL) xor ($this->types & self::TYPE_SCORING)) { + throw new Exception("Invalid problem type: must be exactly one of 'pass-fail' or 'scoring'."); + } + if ($this->types & self::TYPE_SUBMIT_ANSWER) { + if ($this->types & self::TYPE_MULTI_PASS) { + throw new Exception("Invalid problem type: 'submit-answer' and 'multi-pass' are mutually exclusive."); + } + if ($this->types & self::TYPE_INTERACTIVE) { + throw new Exception("Invalid problem type: 'submit-answer' and 'interactive' are mutually exclusive."); + } + } return $this; } + public function isInteractiveProblem(): bool + { + return (bool)($this->types & self::TYPE_INTERACTIVE); + } + public function isMultipassProblem(): bool { - return $this->isMultipassProblem; + return (bool)($this->types & self::TYPE_MULTI_PASS); + } + + public function isPassFailProblem(): bool + { + return (bool)($this->types & self::TYPE_PASS_FAIL); + } + + public function isScoringProblem(): bool + { + return (bool)($this->types & self::TYPE_SCORING); } public function setMultipassLimit(?int $multipassLimit): Problem @@ -317,7 +380,7 @@ public function setMultipassLimit(?int $multipassLimit): Problem public function getMultipassLimit(): int { - if ($this->isMultipassProblem) { + if ($this->isMultipassProblem()) { return $this->multipassLimit ?? 2; } return 1; diff --git a/webapp/src/Form/Type/ProblemType.php b/webapp/src/Form/Type/ProblemType.php index c17cfb4ec4..21f7959c79 100644 --- a/webapp/src/Form/Type/ProblemType.php +++ b/webapp/src/Form/Type/ProblemType.php @@ -8,6 +8,7 @@ use Doctrine\ORM\EntityRepository; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\FileType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\NumberType; @@ -78,13 +79,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'Compare script arguments', 'required' => false, ]); - $builder->add('combinedRunCompare', CheckboxType::class, [ - 'label' => 'Use run script as compare script.', - 'required' => false, - ]); - $builder->add('multipassProblem', CheckboxType::class, [ - 'label' => 'Multi-pass problem', - 'required' => false, + $builder->add('types', ChoiceType::class, [ + 'choices' => [ + 'pass-fail' => Problem::TYPE_PASS_FAIL, + 'interactive' => Problem::TYPE_INTERACTIVE, + 'multipass' => Problem::TYPE_MULTI_PASS, + 'scoring' => Problem::TYPE_SCORING, + 'submit-answer' => Problem::TYPE_SUBMIT_ANSWER, + ], + 'multiple' => true, + 'required' => true, ]); $builder->add('multipassLimit', IntegerType::class, [ 'label' => 'Multi-pass limit', diff --git a/webapp/src/Service/DOMJudgeService.php b/webapp/src/Service/DOMJudgeService.php index faa9163285..26a7d5b4c9 100644 --- a/webapp/src/Service/DOMJudgeService.php +++ b/webapp/src/Service/DOMJudgeService.php @@ -791,7 +791,7 @@ public function printFile( */ public function getSamplesZipContent(ContestProblem $contestProblem): string { - if ($contestProblem->getProblem()->getCombinedRunCompare()) { + if ($contestProblem->getProblem()->isInteractiveProblem()) { throw new NotFoundHttpException(sprintf('Problem p%d has no downloadable samples', $contestProblem->getProbid())); } @@ -892,7 +892,7 @@ public function getSamplesZipForContest(Contest $contest): StreamedResponse /** @var ContestProblem $problem */ foreach ($contest->getProblems() as $problem) { // We don't include the samples for interactive problems. - if (!$problem->getProblem()->getCombinedRunCompare()) { + if (!$problem->getProblem()->isInteractiveProblem()) { $this->addSamplesToZip($zip, $problem, $problem->getShortname()); } @@ -1452,7 +1452,7 @@ public function getCompareConfig(ContestProblem $problem): string 'script_memory_limit' => $this->config->get('script_memory_limit'), 'script_filesize_limit' => $this->config->get('script_filesize_limit'), 'compare_args' => $problem->getProblem()->getSpecialCompareArgs(), - 'combined_run_compare' => $problem->getProblem()->getCombinedRunCompare(), + 'combined_run_compare' => $problem->getProblem()->isInteractiveProblem(), 'hash' => $compareExecutable->getHash(), ] ); diff --git a/webapp/src/Service/ImportProblemService.php b/webapp/src/Service/ImportProblemService.php index 9a705fc032..bb771291ee 100644 --- a/webapp/src/Service/ImportProblemService.php +++ b/webapp/src/Service/ImportProblemService.php @@ -204,10 +204,10 @@ public function importZippedProblem( // The same holds for the timelimit of the problem. if ($problem->getProbid()) { $problem + ->setTypesAsString(['pass-fail']) ->setCompareExecutable() ->setSpecialCompareArgs('') ->setRunExecutable() - ->setCombinedRunCompare(false) ->setMemlimit(null) ->setOutputlimit(null) ->setProblemStatementContent(null) @@ -277,6 +277,15 @@ public function importZippedProblem( $yamlProblemProperties['name'] = $yamlData['name']; } } + + if (isset($yamlData['type'])) { + $types = explode(' ', $yamlData['type']); + // Validation happens later when we set the properties. + $yamlProblemProperties['typesAsString'] = $types; + } else { + $yamlProblemProperties['typesAsString'] = ['pass-fail']; + } + if (isset($yamlData['validator_flags'])) { $yamlProblemProperties['special_compare_args'] = $yamlData['validator_flags']; } @@ -290,7 +299,10 @@ public function importZippedProblem( } if ($yamlData['validation'] == 'custom multi-pass') { - $problem->setMultipassProblem(true); + $yamlProblemProperties['typesAsString'][] = 'multi-pass'; + } + if ($yamlData['validation'] == 'custom interactive') { + $yamlProblemProperties['typesAsString'][] = 'interactive'; } } @@ -307,7 +319,12 @@ public function importZippedProblem( } foreach ($yamlProblemProperties as $key => $value) { - $propertyAccessor->setValue($problem, $key, $value); + try { + $propertyAccessor->setValue($problem, $key, $value); + } catch (Exception $e) { + $messages['danger'][] = sprintf('Error: problem.%s: %s', $key, $e->getMessage()); + return null; + } } } } @@ -1020,7 +1037,6 @@ private function searchAndAddValidator(ZipArchive $zip, ?array &$messages, strin $this->em->persist($executable); if ($combinedRunCompare) { - $problem->setCombinedRunCompare(true); $problem->setRunExecutable($executable); } else { $problem->setCompareExecutable($executable); diff --git a/webapp/templates/jury/problem.html.twig b/webapp/templates/jury/problem.html.twig index fd1b73dfc3..4646c6cc84 100644 --- a/webapp/templates/jury/problem.html.twig +++ b/webapp/templates/jury/problem.html.twig @@ -86,7 +86,7 @@ {% endif %} - {% if problem.combinedRunCompare %} + {% if problem.isInteractiveProblem %} Compare script Run script combines run and compare script. diff --git a/webapp/templates/partials/problem_list.html.twig b/webapp/templates/partials/problem_list.html.twig index bda100c09d..6d7a3f0298 100644 --- a/webapp/templates/partials/problem_list.html.twig +++ b/webapp/templates/partials/problem_list.html.twig @@ -32,7 +32,7 @@ {% for problem in problems %}
{% set numsamples = samples[problem.probid] %} - {% if problem.problem.combinedRunCompare %} + {% if problem.problem.interactiveProblem %} {% set numsamples = 0 %} {% endif %}
@@ -55,6 +55,9 @@ {{ ((problem.problem.memlimit | default(defaultMemoryLimit)) * 1024) | printSize }} {% endif %} +

+ Type: {{ problem.problem.typesAsString }} +

{% if stats is defined %}
diff --git a/webapp/tests/Unit/Controller/Jury/ProblemControllerTest.php b/webapp/tests/Unit/Controller/Jury/ProblemControllerTest.php index cb44eed75e..2611549ded 100644 --- a/webapp/tests/Unit/Controller/Jury/ProblemControllerTest.php +++ b/webapp/tests/Unit/Controller/Jury/ProblemControllerTest.php @@ -40,7 +40,6 @@ class ProblemControllerTest extends JuryControllerTestCase 'problemstatementFile' => '', 'runExecutable' => 'boolfind_cmp', 'compareExecutable' => '', - 'combinedRunCompare' => true, 'specialCompareArgs' => ''], ['name' => '🙃 Unicode in name'], ['name' => 'Long time', @@ -52,8 +51,7 @@ class ProblemControllerTest extends JuryControllerTestCase 'specialCompareArgs' => 'args'], ['name' => 'Args with Unicode', 'specialCompareArgs' => '🙃 #Should not happen'], - ['name' => 'Split Run/Compare', - 'combinedRunCompare' => false], + ['name' => 'Split Run/Compare'], ['externalid' => '._-3xternal1']]; protected static array $addEntitiesFailure = ['This value should not be blank.' => [['name' => '']], 'Only letters, numbers, dashes, underscores and dots are allowed.' => [['externalid' => 'limited_special_chars!']], diff --git a/webapp/tests/Unit/Controller/Team/ProblemControllerTest.php b/webapp/tests/Unit/Controller/Team/ProblemControllerTest.php index aecb994012..57ce216b35 100644 --- a/webapp/tests/Unit/Controller/Team/ProblemControllerTest.php +++ b/webapp/tests/Unit/Controller/Team/ProblemControllerTest.php @@ -80,7 +80,8 @@ function () use ( $card->filter('h4.card-subtitle')->text(null, true) ); } else { - static::assertSame(0, + // The problem type is displayed on the page, so we expect one heading. + static::assertSame(1, $card->filter('h4.card-subtitle')->count()); } @@ -210,7 +211,7 @@ public function testAccessProblemBeforeContestStarts(): void $this->client->request('GET', '/public/problems'); static::assertSelectorTextContains('.nav-item .nav-link.disabled', 'Problems'); static::assertSelectorTextContains('.alert.alert-secondary', 'No problem texts available at this point.'); - + foreach ($probids as $id) { $endpoints = [ "/team/problems/{$id}",