Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
20 changes: 20 additions & 0 deletions claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/Controller/FormatController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/Enum/DeckFormat.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)),
};
}
Expand Down
57 changes: 57 additions & 0 deletions src/Validator/Format/FrontierFormatValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Validator\Format;

use App\Entity\Deck;

/**
* Frontier format rules:
* 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
{
private const string GAMEPLAY_FORMAT_KEY = 'frontier';

public function getFormat(): string
{
return 'frontier';
}

public function validate(Deck $deck, array $cardsData): array
{
return array_merge(parent::validate($deck, $cardsData), $this->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;
}
}
2 changes: 2 additions & 0 deletions tests/Controller/FormatControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +84,7 @@ public static function strictFormatsProvider(): array
['standard'],
['singleton'],
['singleton_nuc'],
['frontier'],
];
}

Expand Down
154 changes: 154 additions & 0 deletions tests/Validator/Format/FrontierFormatValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

namespace App\Tests\Validator\Format;

use App\Entity\Deck;
use App\Entity\DeckCard;
use App\Validator\Format\FrontierFormatValidator;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;

class FrontierFormatValidatorTest extends TestCase
{
private FrontierFormatValidator $validator;

protected function setUp(): void
{
$this->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<string, 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')));
}
}
Loading