diff --git a/Dockerfile b/Dockerfile index b1cbf1b..3e43bbd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -113,6 +113,10 @@ RUN <<-EOF chmod +x bin/console; sync EOF +RUN chown -R 1000:1000 vendor + +RUN chmod -R 775 vendor + # Collect shared libraries needed by FrankenPHP and PHP extensions # hadolint ignore=DL3008,SC3054,DL4006 RUN <<-'EOF' diff --git a/claude.md b/claude.md index 70fe5a3..11ddae1 100644 --- a/claude.md +++ b/claude.md @@ -256,6 +256,26 @@ Card ``` Filters on Card endpoint that target CardGroup fields go through `CardGroupAliasFilter` / `CardGroupOrderFilter`, which create (or reuse) a single `cardGroup` join. + +--- + +## Deck formats + +Formats are not a DB table — they're a `DeckFormat` enum (`src/Enum/DeckFormat.php`) plus one `DeckFormatValidator` per format (`src/Validator/Format/`), all extending `AbstractDeckFormatValidator`. Adding a format needs no migration: just an enum case + a validator class (auto-tagged via `DeckFormatValidatorInterface`) + a metadata entry in `FormatController`. + +### Frontier format — allowlist via `gameplayFormat` on card data + +`FrontierFormatValidator` extends `StandardFormatValidator` (same rules: 39-59 cards, max 3 copies/name, max 3 Unique, max 15 rare, max 3 exalted) and adds one more check: every Unique card in the deck must carry the `"frontier"` key in its `gameplayFormat` array. + +`gameplayFormat` (`string[]`) is a field on `CardGroup` in `altered-core-cards-api`, synced from the Altered Reunion formats manifest and flattened onto each `Card`'s JSON by `CardNormalizer` — it arrives for free in the payload `AlteredCoreClient::getCardsByReferences()` already fetches, no extra call needed: + +```php +$gameplayFormats = $cardsData[$ref]['gameplayFormat'] ?? []; +$isFrontierLegal = in_array('frontier', $gameplayFormats, true); +``` + +This replaces the previous design that called a sibling `uniques-search-api` service for a live allowlist lookup (`UniquesSearchApiClient` / `UNIQUES_SEARCH_API_URL` — both removed). No fail-closed handling is needed anymore: if `AlteredCoreClient` can't fetch card data at all, validation already fails upstream for unrelated reasons. + --- ## Response shape reference diff --git a/src/Controller/FormatController.php b/src/Controller/FormatController.php index c7cc356..d4fdaed 100644 --- a/src/Controller/FormatController.php +++ b/src/Controller/FormatController.php @@ -76,6 +76,21 @@ public function __invoke(Request $request): JsonResponse 'maxCopiesPerRarity' => null, ], ], + [ + 'code' => 'frontier', + 'label' => 'Frontier', + 'minCards' => 39, + 'maxCards' => 59, + 'allowBanned' => false, + 'allowSuspended' => false, + 'limits' => [ + 'unique' => 3, + 'rare' => 15, + 'exalted' => 3, + 'maxCopiesPerName' => 3, + 'maxCopiesPerRarity' => null, + ], + ], [ 'code' => 'singleton', 'label' => 'Singleton', diff --git a/src/Enum/DeckFormat.php b/src/Enum/DeckFormat.php index 527b67d..5758e15 100644 --- a/src/Enum/DeckFormat.php +++ b/src/Enum/DeckFormat.php @@ -10,6 +10,7 @@ enum DeckFormat: string case SingletonNuc = 'singleton_nuc'; case Sandbox = 'sandbox'; case Test = 'test'; + case Frontier = 'frontier'; public static function fromInput(string $value): self { @@ -20,6 +21,7 @@ public static function fromInput(string $value): self 'singleton_nuc', 'singleton_no_unique' => self::SingletonNuc, 'sandbox' => self::Sandbox, 'test' => self::Test, + 'frontier' => self::Frontier, default => throw new \ValueError(sprintf('"%s" is not a valid DeckFormat.', $value)), }; } diff --git a/src/Validator/Format/FrontierFormatValidator.php b/src/Validator/Format/FrontierFormatValidator.php new file mode 100644 index 0000000..a0ef4c6 --- /dev/null +++ b/src/Validator/Format/FrontierFormatValidator.php @@ -0,0 +1,57 @@ +validateFrontierUniques($deck, $cardsData)); + } + + public function computeLegalityDetail(Deck $deck, array $cardsData): array + { + $detail = parent::computeLegalityDetail($deck, $cardsData); + $detail['frontierUniques'] = [] === $this->validateFrontierUniques($deck, $cardsData); + $detail['global'] = $detail['global'] && $detail['frontierUniques']; + + return $detail; + } + + /** + * @return string[] + */ + private function validateFrontierUniques(Deck $deck, array $cardsData): array + { + $errors = []; + foreach ($deck->getDeckCards() as $deckCard) { + $data = $cardsData[$deckCard->getCardReference()] ?? []; + if ('U' !== $this->getRarityCode($data)) { + continue; + } + + $gameplayFormats = $data['gameplayFormat'] ?? []; + if (!in_array(self::GAMEPLAY_FORMAT_KEY, $gameplayFormats, true)) { + $errors[] = sprintf('Unique card "%s" is not part of the Frontier format allowlist.', $this->getCardName($data)); + } + } + + return $errors; + } +} diff --git a/tests/Controller/FormatControllerTest.php b/tests/Controller/FormatControllerTest.php index d048896..9f1ec09 100644 --- a/tests/Controller/FormatControllerTest.php +++ b/tests/Controller/FormatControllerTest.php @@ -22,6 +22,7 @@ public function testFormatsEndpointReturnsAllFormats(): void self::assertContains('standard', $codes); self::assertContains('singleton', $codes); self::assertContains('singleton_nuc', $codes); + self::assertContains('frontier', $codes); } public function testHiddenFormatsAreNotReturnedByDefault(): void @@ -83,6 +84,7 @@ public static function strictFormatsProvider(): array ['standard'], ['singleton'], ['singleton_nuc'], + ['frontier'], ]; } diff --git a/tests/Validator/Format/FrontierFormatValidatorTest.php b/tests/Validator/Format/FrontierFormatValidatorTest.php new file mode 100644 index 0000000..763a381 --- /dev/null +++ b/tests/Validator/Format/FrontierFormatValidatorTest.php @@ -0,0 +1,154 @@ +validator = new FrontierFormatValidator(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function card(string $ref, int $qty = 1): DeckCard + { + $card = new DeckCard(); + $card->setCardReference($ref); + $card->setQuantity($qty); + + return $card; + } + + private function deck(DeckCard ...$cards): Deck + { + $deck = $this->createStub(Deck::class); + $deck->method('getDeckCards')->willReturn(new ArrayCollection($cards)); + + return $deck; + } + + private function data( + string $ref, + string $typeRef = 'PERMANENT', + string $faction = 'AX', + string $rarityRef = 'COMMON', + string $name = '', + array $gameplayFormat = [], + ): array { + return [ + 'reference' => $ref, + 'cardType' => ['reference' => $typeRef], + 'faction' => ['code' => $faction], + 'rarity' => ['reference' => $rarityRef], + 'name' => $name ?: $ref, + 'gameplayFormat' => $gameplayFormat, + ]; + } + + /** + * Builds 1 hero + 13 distinct commons × qty 3 = 39 non-hero cards. + * + * @return array{0: array, 1: DeckCard[]} + */ + private function buildMinimalValidDeck(string $faction = 'AX'): array + { + $cardsData = []; + $deckCards = []; + + $heroRef = 'ALT_CORE_B_AX_0_C'; + $deckCards[] = $this->card($heroRef, 1); + $cardsData[$heroRef] = $this->data($heroRef, 'HERO_MAIN', $faction); + + for ($i = 1; $i <= 13; ++$i) { + $ref = sprintf('ALT_CORE_B_%s_%d_C', $faction, $i); + $deckCards[] = $this->card($ref, 3); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', $faction); + } + + return [$cardsData, $deckCards]; + } + + // ── Happy path ──────────────────────────────────────────────────────────── + + public function testDeckWithNoUniqueIsValid(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertSame([], $errors); + } + + public function testAllUniquesFlaggedFrontierIsValid(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + for ($i = 1; $i <= 3; ++$i) { + $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); + $deckCards[] = $this->card($ref, 1); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i", ['frontier']); + } + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertSame([], $errors); + } + + // ── Frontier allowlist ──────────────────────────────────────────────────── + + public function testUniqueNotFlaggedFrontierReturnsError(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $allowedRef = 'ALT_CORE_B_AX_1_U'; + $rejectedRef = 'ALT_CORE_B_AX_2_U'; + $deckCards[] = $this->card($allowedRef, 1); + $deckCards[] = $this->card($rejectedRef, 1); + $cardsData[$allowedRef] = $this->data($allowedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Allowed Unique', ['frontier']); + $cardsData[$rejectedRef] = $this->data($rejectedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Rejected Unique', []); + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'Rejected Unique') && str_contains($e, 'Frontier'))); + } + + public function testUniqueNotFlaggedFrontierMarksLegalityDetailFalse(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $rejectedRef = 'ALT_CORE_B_AX_1_U'; + $deckCards[] = $this->card($rejectedRef, 1); + $cardsData[$rejectedRef] = $this->data($rejectedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Rejected Unique', []); + + $detail = $this->validator->computeLegalityDetail($this->deck(...$deckCards), $cardsData); + + self::assertFalse($detail['frontierUniques']); + self::assertFalse($detail['global']); + } + + // ── Inherited Standard rules still apply ───────────────────────────────── + + public function testInheritsStandardMaxUniqueLimit(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + for ($i = 1; $i <= 4; ++$i) { + $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); + $deckCards[] = $this->card($ref, 1); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i", ['frontier']); + } + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'Unique cards'))); + } +}