From 8710a055509de60f4f07fb24f04004625ae4adff Mon Sep 17 00:00:00 2001 From: Jules Date: Mon, 6 Jul 2026 00:51:34 +0200 Subject: [PATCH 1/2] frontier format --- Dockerfile | 4 + claude.md | 21 ++ config/services.yaml | 4 + config/services_test.yaml | 10 + src/Client/UniquesSearchApiClient.php | 40 ++++ src/Controller/FormatController.php | 15 ++ src/Enum/DeckFormat.php | 2 + .../Format/FrontierFormatValidator.php | 76 +++++++ tests/Controller/FormatControllerTest.php | 2 + .../Format/FrontierFormatValidatorTest.php | 204 ++++++++++++++++++ 10 files changed, 378 insertions(+) create mode 100644 src/Client/UniquesSearchApiClient.php create mode 100644 src/Validator/Format/FrontierFormatValidator.php create mode 100644 tests/Validator/Format/FrontierFormatValidatorTest.php 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..4cd1569 100644 --- a/claude.md +++ b/claude.md @@ -256,6 +256,27 @@ 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 — external allowlist via uniques-search-api + +`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 be part of the Frontier allowlist (~30 000 references), which is **not** stored in this repo. It's verified by calling `uniques-search-api` (sibling repo, Rust/Axum): + +``` +GET {UNIQUES_SEARCH_API_URL}/api/v2/cards?ref=REF1,REF2,REF3&format=frontier +``` + +The allowlist itself lives in `uniques-search-api` as `formats/frontier.json` (`included_refs`), loaded from disk — this repo never sees the 30k list directly, only pass/fail per reference. + +- **Fail-closed**: if `uniques-search-api` is unreachable, the deck is invalid (`UniquesSearchApiClient` exceptions are caught in `FrontierFormatValidator` and turned into a validation error, never propagated). +- `UNIQUES_SEARCH_API_URL` env var must be set (same pattern as `ALTERED_CORE_URL` for `AlteredCoreClient`). +- In local dev via `altered-dev-environment`, the URL is wired in `apphost.cs` from the `uniques` service. + --- ## Response shape reference diff --git a/config/services.yaml b/config/services.yaml index 48b9f2c..228951a 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -49,6 +49,10 @@ services: arguments: $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' + App\Client\UniquesSearchApiClient: + arguments: + $uniquesSearchApiUrl: '%env(UNIQUES_SEARCH_API_URL)%' + App\Validator\Format\DeckFormatValidatorFactory: arguments: - !tagged_iterator app.deck_format_validator diff --git a/config/services_test.yaml b/config/services_test.yaml index 8973d35..6b91f8d 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -11,3 +11,13 @@ services: arguments: $httpClient: '@altered_core.mock_http_client' $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' + + uniques_search_api.mock_http_client: + class: Symfony\Component\HttpClient\MockHttpClient + public: true + + App\Client\UniquesSearchApiClient: + autowire: true + arguments: + $httpClient: '@uniques_search_api.mock_http_client' + $uniquesSearchApiUrl: '%env(UNIQUES_SEARCH_API_URL)%' diff --git a/src/Client/UniquesSearchApiClient.php b/src/Client/UniquesSearchApiClient.php new file mode 100644 index 0000000..b923dfa --- /dev/null +++ b/src/Client/UniquesSearchApiClient.php @@ -0,0 +1,40 @@ +httpClient->request('GET', $this->uniquesSearchApiUrl.'/api/v2/cards', [ + 'query' => [ + 'ref' => implode(',', $references), + 'format' => $format, + ], + ]); + + $data = $response->toArray(); + + return array_column($data['cards'] ?? [], '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..77f4936 --- /dev/null +++ b/src/Validator/Format/FrontierFormatValidator.php @@ -0,0 +1,76 @@ +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 + { + $uniqueRefs = []; + foreach ($deck->getDeckCards() as $deckCard) { + $data = $cardsData[$deckCard->getCardReference()] ?? []; + if ('U' === $this->getRarityCode($data)) { + $uniqueRefs[] = $deckCard->getCardReference(); + } + } + + if (empty($uniqueRefs)) { + return []; + } + + try { + $legalRefs = $this->uniquesSearchApiClient->findLegalReferences($uniqueRefs, 'frontier'); + } catch (\Throwable) { + return ['Unable to verify Frontier format legality: uniques search service is unavailable.']; + } + + $illegalRefs = array_diff($uniqueRefs, $legalRefs); + if (empty($illegalRefs)) { + return []; + } + + $errors = []; + foreach ($illegalRefs as $ref) { + $name = $this->getCardName($cardsData[$ref] ?? []) ?: $ref; + $errors[] = sprintf('Unique card "%s" is not part of the Frontier format allowlist.', $name); + } + + 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..ab0594c --- /dev/null +++ b/tests/Validator/Format/FrontierFormatValidatorTest.php @@ -0,0 +1,204 @@ +uniquesSearchApiClient = $this->createStub(UniquesSearchApiClient::class); + $this->validator = new FrontierFormatValidator($this->uniquesSearchApiClient); + } + + // ── 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 { + return [ + 'reference' => $ref, + 'cardType' => ['reference' => $typeRef], + 'faction' => ['code' => $faction], + 'rarity' => ['reference' => $rarityRef], + 'name' => $name ?: $ref, + ]; + } + + /** + * 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 testDeckWithNoUniqueDoesNotCallSearchApi(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $client = $this->createMock(UniquesSearchApiClient::class); + $client->expects(self::never())->method('findLegalReferences'); + $validator = new FrontierFormatValidator($client); + + $errors = $validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertSame([], $errors); + } + + public function testAllUniquesAllowedByFrontierIsValid(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $uniqueRefs = []; + for ($i = 1; $i <= 3; ++$i) { + $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); + $uniqueRefs[] = $ref; + $deckCards[] = $this->card($ref, 1); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i"); + } + + $client = $this->createMock(UniquesSearchApiClient::class); + $client->expects(self::once()) + ->method('findLegalReferences') + ->with($uniqueRefs, 'frontier') + ->willReturn($uniqueRefs); + $validator = new FrontierFormatValidator($client); + + $errors = $validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertSame([], $errors); + } + + // ── Frontier allowlist ──────────────────────────────────────────────────── + + public function testUniqueNotInFrontierAllowlistReturnsError(): 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'); + $cardsData[$rejectedRef] = $this->data($rejectedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Rejected Unique'); + + $this->uniquesSearchApiClient + ->method('findLegalReferences') + ->willReturn([$allowedRef]); + + $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 testUniqueNotInFrontierAllowlistMarksLegalityDetailFalse(): 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'); + + $this->uniquesSearchApiClient + ->method('findLegalReferences') + ->willReturn([]); + + $detail = $this->validator->computeLegalityDetail($this->deck(...$deckCards), $cardsData); + + self::assertFalse($detail['frontierUniques']); + self::assertFalse($detail['global']); + } + + // ── Fail-closed on service failure ─────────────────────────────────────── + + public function testSearchApiFailureMakesDeckInvalid(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $ref = 'ALT_CORE_B_AX_1_U'; + $deckCards[] = $this->card($ref, 1); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', 'My Unique'); + + $this->uniquesSearchApiClient + ->method('findLegalReferences') + ->willThrowException(new \RuntimeException('service unavailable')); + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + $detail = $this->validator->computeLegalityDetail($this->deck(...$deckCards), $cardsData); + + self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'unavailable'))); + self::assertFalse($detail['frontierUniques']); + self::assertFalse($detail['global']); + } + + // ── Inherited Standard rules still apply ───────────────────────────────── + + public function testInheritsStandardMaxUniqueLimit(): void + { + [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); + + $uniqueRefs = []; + for ($i = 1; $i <= 4; ++$i) { + $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); + $uniqueRefs[] = $ref; + $deckCards[] = $this->card($ref, 1); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i"); + } + + $this->uniquesSearchApiClient + ->method('findLegalReferences') + ->willReturn($uniqueRefs); + + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); + + self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'Unique cards'))); + } +} From 9fc1a79d618f8b41fb0f2428d1a435f70c375bcd Mon Sep 17 00:00:00 2001 From: Jules Date: Tue, 7 Jul 2026 18:47:44 +0200 Subject: [PATCH 2/2] adapt to use gameplayFormat --- claude.md | 17 ++-- config/services.yaml | 4 - config/services_test.yaml | 10 --- src/Client/UniquesSearchApiClient.php | 40 ---------- .../Format/FrontierFormatValidator.php | 43 +++------- .../Format/FrontierFormatValidatorTest.php | 78 ++++--------------- 6 files changed, 34 insertions(+), 158 deletions(-) delete mode 100644 src/Client/UniquesSearchApiClient.php diff --git a/claude.md b/claude.md index 4cd1569..11ddae1 100644 --- a/claude.md +++ b/claude.md @@ -263,19 +263,18 @@ Filters on Card endpoint that target CardGroup fields go through `CardGroupAlias 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 — external allowlist via uniques-search-api +### 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 be part of the Frontier allowlist (~30 000 references), which is **not** stored in this repo. It's verified by calling `uniques-search-api` (sibling repo, Rust/Axum): +`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. -``` -GET {UNIQUES_SEARCH_API_URL}/api/v2/cards?ref=REF1,REF2,REF3&format=frontier -``` +`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: -The allowlist itself lives in `uniques-search-api` as `formats/frontier.json` (`included_refs`), loaded from disk — this repo never sees the 30k list directly, only pass/fail per reference. +```php +$gameplayFormats = $cardsData[$ref]['gameplayFormat'] ?? []; +$isFrontierLegal = in_array('frontier', $gameplayFormats, true); +``` -- **Fail-closed**: if `uniques-search-api` is unreachable, the deck is invalid (`UniquesSearchApiClient` exceptions are caught in `FrontierFormatValidator` and turned into a validation error, never propagated). -- `UNIQUES_SEARCH_API_URL` env var must be set (same pattern as `ALTERED_CORE_URL` for `AlteredCoreClient`). -- In local dev via `altered-dev-environment`, the URL is wired in `apphost.cs` from the `uniques` service. +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. --- diff --git a/config/services.yaml b/config/services.yaml index 228951a..48b9f2c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -49,10 +49,6 @@ services: arguments: $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' - App\Client\UniquesSearchApiClient: - arguments: - $uniquesSearchApiUrl: '%env(UNIQUES_SEARCH_API_URL)%' - App\Validator\Format\DeckFormatValidatorFactory: arguments: - !tagged_iterator app.deck_format_validator diff --git a/config/services_test.yaml b/config/services_test.yaml index 6b91f8d..8973d35 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -11,13 +11,3 @@ services: arguments: $httpClient: '@altered_core.mock_http_client' $alteredCoreUrl: '%env(ALTERED_CORE_URL)%' - - uniques_search_api.mock_http_client: - class: Symfony\Component\HttpClient\MockHttpClient - public: true - - App\Client\UniquesSearchApiClient: - autowire: true - arguments: - $httpClient: '@uniques_search_api.mock_http_client' - $uniquesSearchApiUrl: '%env(UNIQUES_SEARCH_API_URL)%' diff --git a/src/Client/UniquesSearchApiClient.php b/src/Client/UniquesSearchApiClient.php deleted file mode 100644 index b923dfa..0000000 --- a/src/Client/UniquesSearchApiClient.php +++ /dev/null @@ -1,40 +0,0 @@ -httpClient->request('GET', $this->uniquesSearchApiUrl.'/api/v2/cards', [ - 'query' => [ - 'ref' => implode(',', $references), - 'format' => $format, - ], - ]); - - $data = $response->toArray(); - - return array_column($data['cards'] ?? [], 'reference'); - } -} diff --git a/src/Validator/Format/FrontierFormatValidator.php b/src/Validator/Format/FrontierFormatValidator.php index 77f4936..a0ef4c6 100644 --- a/src/Validator/Format/FrontierFormatValidator.php +++ b/src/Validator/Format/FrontierFormatValidator.php @@ -2,21 +2,18 @@ namespace App\Validator\Format; -use App\Client\UniquesSearchApiClient; use App\Entity\Deck; /** * Frontier format rules: - * Same as Standard, plus every Unique card in the deck must be part of the - * Frontier allowlist, verified against the uniques search API. - * If that service is unreachable, the deck is considered invalid (fail-closed). + * Same as Standard, plus every Unique card in the deck must carry the + * "frontier" gameplay format key. That key is synced onto CardGroup by + * altered-core-cards-api from the Altered Reunion formats manifest and + * exposed on card data as `gameplayFormat` (string[]). */ class FrontierFormatValidator extends StandardFormatValidator { - public function __construct( - private readonly UniquesSearchApiClient $uniquesSearchApiClient, - ) { - } + private const string GAMEPLAY_FORMAT_KEY = 'frontier'; public function getFormat(): string { @@ -42,33 +39,17 @@ public function computeLegalityDetail(Deck $deck, array $cardsData): array */ private function validateFrontierUniques(Deck $deck, array $cardsData): array { - $uniqueRefs = []; + $errors = []; foreach ($deck->getDeckCards() as $deckCard) { $data = $cardsData[$deckCard->getCardReference()] ?? []; - if ('U' === $this->getRarityCode($data)) { - $uniqueRefs[] = $deckCard->getCardReference(); + if ('U' !== $this->getRarityCode($data)) { + continue; } - } - if (empty($uniqueRefs)) { - return []; - } - - try { - $legalRefs = $this->uniquesSearchApiClient->findLegalReferences($uniqueRefs, 'frontier'); - } catch (\Throwable) { - return ['Unable to verify Frontier format legality: uniques search service is unavailable.']; - } - - $illegalRefs = array_diff($uniqueRefs, $legalRefs); - if (empty($illegalRefs)) { - return []; - } - - $errors = []; - foreach ($illegalRefs as $ref) { - $name = $this->getCardName($cardsData[$ref] ?? []) ?: $ref; - $errors[] = sprintf('Unique card "%s" is not part of the Frontier format allowlist.', $name); + $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/Validator/Format/FrontierFormatValidatorTest.php b/tests/Validator/Format/FrontierFormatValidatorTest.php index ab0594c..763a381 100644 --- a/tests/Validator/Format/FrontierFormatValidatorTest.php +++ b/tests/Validator/Format/FrontierFormatValidatorTest.php @@ -2,7 +2,6 @@ namespace App\Tests\Validator\Format; -use App\Client\UniquesSearchApiClient; use App\Entity\Deck; use App\Entity\DeckCard; use App\Validator\Format\FrontierFormatValidator; @@ -11,13 +10,11 @@ class FrontierFormatValidatorTest extends TestCase { - private UniquesSearchApiClient $uniquesSearchApiClient; private FrontierFormatValidator $validator; protected function setUp(): void { - $this->uniquesSearchApiClient = $this->createStub(UniquesSearchApiClient::class); - $this->validator = new FrontierFormatValidator($this->uniquesSearchApiClient); + $this->validator = new FrontierFormatValidator(); } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -45,6 +42,7 @@ private function data( string $faction = 'AX', string $rarityRef = 'COMMON', string $name = '', + array $gameplayFormat = [], ): array { return [ 'reference' => $ref, @@ -52,6 +50,7 @@ private function data( 'faction' => ['code' => $faction], 'rarity' => ['reference' => $rarityRef], 'name' => $name ?: $ref, + 'gameplayFormat' => $gameplayFormat, ]; } @@ -80,46 +79,33 @@ private function buildMinimalValidDeck(string $faction = 'AX'): array // ── Happy path ──────────────────────────────────────────────────────────── - public function testDeckWithNoUniqueDoesNotCallSearchApi(): void + public function testDeckWithNoUniqueIsValid(): void { [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); - $client = $this->createMock(UniquesSearchApiClient::class); - $client->expects(self::never())->method('findLegalReferences'); - $validator = new FrontierFormatValidator($client); - - $errors = $validator->validate($this->deck(...$deckCards), $cardsData); + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); self::assertSame([], $errors); } - public function testAllUniquesAllowedByFrontierIsValid(): void + public function testAllUniquesFlaggedFrontierIsValid(): void { [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); - $uniqueRefs = []; for ($i = 1; $i <= 3; ++$i) { $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); - $uniqueRefs[] = $ref; $deckCards[] = $this->card($ref, 1); - $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i"); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i", ['frontier']); } - $client = $this->createMock(UniquesSearchApiClient::class); - $client->expects(self::once()) - ->method('findLegalReferences') - ->with($uniqueRefs, 'frontier') - ->willReturn($uniqueRefs); - $validator = new FrontierFormatValidator($client); - - $errors = $validator->validate($this->deck(...$deckCards), $cardsData); + $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); self::assertSame([], $errors); } // ── Frontier allowlist ──────────────────────────────────────────────────── - public function testUniqueNotInFrontierAllowlistReturnsError(): void + public function testUniqueNotFlaggedFrontierReturnsError(): void { [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); @@ -127,54 +113,24 @@ public function testUniqueNotInFrontierAllowlistReturnsError(): void $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'); - $cardsData[$rejectedRef] = $this->data($rejectedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Rejected Unique'); - - $this->uniquesSearchApiClient - ->method('findLegalReferences') - ->willReturn([$allowedRef]); + $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 testUniqueNotInFrontierAllowlistMarksLegalityDetailFalse(): void + 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'); - - $this->uniquesSearchApiClient - ->method('findLegalReferences') - ->willReturn([]); - - $detail = $this->validator->computeLegalityDetail($this->deck(...$deckCards), $cardsData); - - self::assertFalse($detail['frontierUniques']); - self::assertFalse($detail['global']); - } - - // ── Fail-closed on service failure ─────────────────────────────────────── - - public function testSearchApiFailureMakesDeckInvalid(): void - { - [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); - - $ref = 'ALT_CORE_B_AX_1_U'; - $deckCards[] = $this->card($ref, 1); - $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', 'My Unique'); - - $this->uniquesSearchApiClient - ->method('findLegalReferences') - ->willThrowException(new \RuntimeException('service unavailable')); + $cardsData[$rejectedRef] = $this->data($rejectedRef, 'PERMANENT', 'AX', 'UNIQUE', 'Rejected Unique', []); - $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); $detail = $this->validator->computeLegalityDetail($this->deck(...$deckCards), $cardsData); - self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'unavailable'))); self::assertFalse($detail['frontierUniques']); self::assertFalse($detail['global']); } @@ -185,18 +141,12 @@ public function testInheritsStandardMaxUniqueLimit(): void { [$cardsData, $deckCards] = $this->buildMinimalValidDeck(); - $uniqueRefs = []; for ($i = 1; $i <= 4; ++$i) { $ref = sprintf('ALT_CORE_B_AX_%d_U', $i); - $uniqueRefs[] = $ref; $deckCards[] = $this->card($ref, 1); - $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i"); + $cardsData[$ref] = $this->data($ref, 'PERMANENT', 'AX', 'UNIQUE', "Unique $i", ['frontier']); } - $this->uniquesSearchApiClient - ->method('findLegalReferences') - ->willReturn($uniqueRefs); - $errors = $this->validator->validate($this->deck(...$deckCards), $cardsData); self::assertNotEmpty(array_filter($errors, fn ($e) => str_contains($e, 'Unique cards')));