# Mecze w wielu formatach — plan wdrożenia

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Rozszerzyć aplikację o mecze doraźne w formatach 1v1, 2v2 i 3v3 obok istniejącej ligi 4x4, z jedną stroną na kolejkę pokazującą wszystkie mecze.

**Architecture:** Mecz zyskuje format. Liczenie wyniku rozdziela się na trzy implementacje wspólnego kontraktu (miejsca dla 4x4, suma dla 1v1 i 2v2, pojedynki dla 3v3), wybierane po formacie przez rejestr. Strona kolejki zastępuje stronę meczu i renderuje wszystkie mecze danej kolejki tym samym blokiem szablonu.

**Tech Stack:** PHP 8.4, Symfony 7.3, Doctrine ORM 3.6, MySQL 8.4, PHPUnit 13.3, Twig. Zero budowania frontendu — statyczny CSS, brak JavaScriptu.

**Spec:** `docs/superpowers/specs/2026-08-22-rozgrywki-wieloformatowe-design.md`

## Global Constraints

- Język kodu, komentarzy i komunikatów: polski bez znaków diakrytycznych w kodzie PHP; teksty widoczne dla użytkownika w szablonach też bez diakrytyków (tak jest w całej aplikacji dzisiaj).
- Zero JavaScriptu i zero budowania frontendu. Formularze działają na czystym HTML.
- Każda zmiana zachowania jest poprzedzona testem, który najpierw pada.
- Cała suita (`vendor/bin/phpunit`) musi być zielona przed każdym commitem.
- Punkty ligowe istnieją wyłącznie w formacie 4x4. Pozostałe formaty zapisują tylko rozstrzygnięcie.
- W jednej kolejce może istnieć najwyżej jeden mecz 4x4; meczów doraźnych dowolna liczba.
- Nie kasujemy ani nie przepisujemy istniejących danych — migracja tylko dodaje.
- Nazwy chipów FPL zostają oryginalne (BB, TC, FH, WC).

---

## Struktura plików

**Nowe:**

| Plik | Odpowiedzialność |
|---|---|
| `src/League/MatchFormat.php` | Wyliczenie formatów: rozmiar składu, etykiety, czy używa punktów ligowych i pojedynków |
| `src/Entity/Duel.php` | Pojedynek w trójkach: mecz, numer, moja drużyna, ich drużyna |
| `src/League/MatchScorer.php` | Kontrakt liczenia wyniku |
| `src/League/MatchScorers.php` | Rejestr wybierający implementację po formacie |
| `src/League/SumScorer.php` | Wynik z sumy dorobku (pojedynek, duety) |
| `src/League/DuelsScorer.php` | Wynik z liczby wygranych pojedynków (trójki) |
| `src/League/DuelPair.php` | Para do rozegrania: moja drużyna kontra ich |
| `src/League/DuelResult.php` | Rozstrzygnięty pojedynek |
| `src/Controller/GameweekController.php` | Strona kolejki ze wszystkimi meczami |
| `templates/gameweek/show.html.twig` | Strona kolejki |
| `templates/fixture/_match.html.twig` | Jeden mecz: nagłówek, wynik, tabela, różnice, składy |
| `templates/fixture/_duels.html.twig` | Lista pojedynków w trójkach |
| `migrations/Version20260822120000.php` | Format meczu, tabela pojedynków, zdjęcie unikalności kolejki |

**Modyfikowane:**

| Plik | Zmiana |
|---|---|
| `src/Entity/Squad.php` | `SIZE` ustępuje miejsca `MAX_SIZE`; `isComplete()` przyjmuje oczekiwany rozmiar |
| `src/Entity/LeagueFixture.php` | Kolumna formatu, kolekcja pojedynków, zdjęcie unikalności kolejki |
| `src/League/SquadManager.php` | Rozmiar składu jako parametr, tworzenie składu dowolnej strony |
| `src/League/LeagueScorer.php` | Implementuje kontrakt `MatchScorer` |
| `src/League/MatchResult.php` | Niesie rozstrzygnięte pojedynki |
| `src/League/MatchViewBuilder.php` | Wybiera implementację po formacie meczu |
| `src/Repository/LeagueFixtureRepository.php` | Mecze kolejki, mecz 4x4 kolejki, mecze 4x4 sezonu |
| `src/Controller/FixtureController.php` | Formularz z formatem, obie strony, pary; przekierowania na stronę kolejki |
| `src/Controller/DashboardController.php` | Bilans liczony wyłącznie z meczów 4x4 |
| `src/Form/FixtureFormModel.php` | Format, moja strona, pary pojedynków |
| `src/Form/FixtureFormType.php` | Pola dla powyższych |
| `templates/base.html.twig` | Nazwa w pasku, odnośnik do bieżącej kolejki |
| `templates/dashboard/index.html.twig` | Odnośnik na stronę kolejki |
| `templates/fixture/new.html.twig` | Wybór formatu, moja strona, pary |
| `public/css/app.css` | Nagłówek meczu, lista pojedynków |

**Usuwane:**

| Plik | Powód |
|---|---|
| `templates/fixture/show.html.twig` | Zastąpiony przez `gameweek/show.html.twig` plus `fixture/_match.html.twig` |

---

### Task 1: Format meczu i rozmiar składu

**Files:**
- Create: `src/League/MatchFormat.php`
- Create: `tests/Unit/League/MatchFormatTest.php`
- Modify: `src/Entity/Squad.php`
- Modify: `src/League/SquadManager.php`
- Modify: `src/Controller/FixtureController.php:51`
- Test: `tests/Unit/League/MatchFormatTest.php`, `tests/Integration/SquadManagerTest.php`

**Interfaces:**
- Consumes: nic z wcześniejszych zadań.
- Produces:
  - `App\League\MatchFormat` — enum `string`, przypadki `Duel = 'duel'`, `Duo = 'duo'`, `Trio = 'trio'`, `Squad4 = 'four'`; metody `squadSize(): int`, `label(): string`, `scoreLabel(): string`, `usesLeaguePoints(): bool`, `usesDuels(): bool`.
  - `App\Entity\Squad::MAX_SIZE` (int, 4), `Squad::isComplete(int $expectedSize): bool`, `Squad::size(): int`.
  - `App\League\SquadManager::createSideSquad(string $name, bool $mine, array $entryIds, int $expectedSize): Squad`.
  - `App\League\SquadManager::saveMySquad(string $name, array $entryIds): Squad` — bez zmian w sygnaturze, w środku używa rozmiaru `MatchFormat::Squad4->squadSize()`.

- [ ] **Step 1: Napisz padający test formatu**

Utwórz `tests/Unit/League/MatchFormatTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\League\MatchFormat;
use PHPUnit\Framework\TestCase;

final class MatchFormatTest extends TestCase
{
    public function testSquadSizeMatchesTheFormatName(): void
    {
        self::assertSame(1, MatchFormat::Duel->squadSize());
        self::assertSame(2, MatchFormat::Duo->squadSize());
        self::assertSame(3, MatchFormat::Trio->squadSize());
        self::assertSame(4, MatchFormat::Squad4->squadSize());
    }

    public function testOnlyFourAgainstFourAwardsLeaguePoints(): void
    {
        // Punkty ligowe biora sie z podzialu puli 28 miedzy osiem druzyn —
        // w pozostalych formatach nie ma czego dzielic.
        self::assertTrue(MatchFormat::Squad4->usesLeaguePoints());
        self::assertFalse(MatchFormat::Duel->usesLeaguePoints());
        self::assertFalse(MatchFormat::Duo->usesLeaguePoints());
        self::assertFalse(MatchFormat::Trio->usesLeaguePoints());
    }

    public function testOnlyTrioUsesDuels(): void
    {
        self::assertTrue(MatchFormat::Trio->usesDuels());
        self::assertFalse(MatchFormat::Duel->usesDuels());
        self::assertFalse(MatchFormat::Duo->usesDuels());
        self::assertFalse(MatchFormat::Squad4->usesDuels());
    }

    public function testScoreLabelSaysWhatTheHeadlineNumberMeans(): void
    {
        self::assertSame('pkt ligowe', MatchFormat::Squad4->scoreLabel());
        self::assertSame('pkt FPL', MatchFormat::Duel->scoreLabel());
        self::assertSame('pkt FPL', MatchFormat::Duo->scoreLabel());
        self::assertSame('pojedynki', MatchFormat::Trio->scoreLabel());
    }

    public function testEveryFormatHasADistinctHumanLabel(): void
    {
        $labels = array_map(static fn (MatchFormat $f): string => $f->label(), MatchFormat::cases());

        self::assertSame(['Pojedynek', 'Duety', 'Trojki', '4x4'], $labels);
    }
}
```

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter MatchFormatTest`
Expected: FAIL — `Class "App\League\MatchFormat" not found`

- [ ] **Step 3: Napisz enum**

Utwórz `src/League/MatchFormat.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/**
 * Format meczu decyduje o wszystkim, co odroznia rozgrywki od siebie: ile
 * druzyn stoi po stronie, jak liczy sie wynik i co znaczy liczba pokazywana
 * przy nazwie strony.
 */
enum MatchFormat: string
{
    case Duel = 'duel';
    case Duo = 'duo';
    case Trio = 'trio';
    case Squad4 = 'four';

    public function squadSize(): int
    {
        return match ($this) {
            self::Duel => 1,
            self::Duo => 2,
            self::Trio => 3,
            self::Squad4 => 4,
        };
    }

    public function label(): string
    {
        return match ($this) {
            self::Duel => 'Pojedynek',
            self::Duo => 'Duety',
            self::Trio => 'Trojki',
            self::Squad4 => '4x4',
        };
    }

    /** Co znaczy liczba przy nazwie strony na ekranie wyniku. */
    public function scoreLabel(): string
    {
        return match ($this) {
            self::Duel, self::Duo => 'pkt FPL',
            self::Trio => 'pojedynki',
            self::Squad4 => 'pkt ligowe',
        };
    }

    public function usesLeaguePoints(): bool
    {
        return self::Squad4 === $this;
    }

    public function usesDuels(): bool
    {
        return self::Trio === $this;
    }
}
```

- [ ] **Step 4: Uruchom test, potwierdź że przechodzi**

Run: `vendor/bin/phpunit --filter MatchFormatTest`
Expected: PASS (5 testów)

- [ ] **Step 5: Napisz padający test rozmiaru składu**

Dopisz do `tests/Integration/SquadManagerTest.php` (plik istnieje; dodaj metody na końcu klasy, przed zamykającym nawiasem). Istniejąca metoda `manager(array $responses)` przyjmuje listę odpowiedzi atrapy HTTP — po jednej na drużynę, której nie ma jeszcze w bazie:

```php
    public function testCreatesSquadOfTheSizeTheFormatRequires(): void
    {
        $responses = [ApiFixtures::response('entry_101'), ApiFixtures::response('entry_101')];

        $squad = $this->manager($responses)->createSideSquad('Duet', false, [201, 202], 2);

        self::assertCount(2, $squad->entries());
        self::assertTrue($squad->isComplete(2));
        self::assertFalse($squad->isComplete(4), 'Duet nie jest kompletna czworka.');
    }

    public function testRejectsSquadWithWrongNumberOfEntries(): void
    {
        // Rozmiar sprawdza sie przed odpytaniem API, wiec atrapa nie potrzebuje
        // zadnej odpowiedzi — gdyby ktorakolwiek poszla, test by to wykryl.
        $this->expectException(\InvalidArgumentException::class);

        $this->manager([])->createSideSquad('Duet', false, [201, 202, 203], 2);
    }
```

- [ ] **Step 6: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter SquadManagerTest`
Expected: FAIL — `Call to undefined method App\League\SquadManager::createSideSquad()`

- [ ] **Step 7: Uelastycznij rozmiar składu w encji**

W `src/Entity/Squad.php` zamień stałą i obie metody:

```php
    /** Najwiekszy dopuszczalny sklad — 4x4. Ile druzyn potrzebuje konkretny mecz, mowi jego format. */
    public const MAX_SIZE = 4;
```

```php
    public function setMember(int $slot, FplEntry $entry): void
    {
        if ($slot < 1 || $slot > self::MAX_SIZE) {
            throw new \InvalidArgumentException(sprintf('Slot musi byc z zakresu 1-%d, podano %d.', self::MAX_SIZE, $slot));
        }
```

```php
    public function size(): int
    {
        return count($this->members);
    }

    public function isComplete(int $expectedSize): bool
    {
        return $this->size() === $expectedSize;
    }
```

- [ ] **Step 8: Uelastycznij rozmiar w SquadManager**

W `src/League/SquadManager.php` zamień `saveMySquad`, `createOpponentSquad` i `assertValidIds`:

```php
    /** @param list<int> $entryIds */
    public function saveMySquad(string $name, array $entryIds): Squad
    {
        $size = MatchFormat::Squad4->squadSize();
        $this->assertValidIds($entryIds, $size);

        $squad = $this->squads->findMine() ?? new Squad($name, true, $this->clock->now());
        $squad->rename($name);
        $this->fill($squad, $entryIds);

        $this->em->persist($squad);
        $this->em->flush();

        return $squad;
    }

    /**
     * Tworzy sklad jednej strony meczu. Kazdy mecz dostaje wlasne kopie skladow,
     * zeby pozniejsza zmiana stalej czworki nie przepisywala historii rozegranych
     * juz kolejek.
     *
     * @param list<int> $entryIds
     */
    public function createSideSquad(string $name, bool $mine, array $entryIds, int $expectedSize): Squad
    {
        $this->assertValidIds($entryIds, $expectedSize);

        $squad = new Squad($name, $mine, $this->clock->now());
        $this->fill($squad, $entryIds);

        $this->em->persist($squad);
        $this->em->flush();

        return $squad;
    }

    /** @param list<int> $entryIds */
    private function assertValidIds(array $entryIds, int $expectedSize): void
    {
        if (count($entryIds) !== $expectedSize) {
            throw new \InvalidArgumentException(sprintf('Strona musi miec dokladnie %d identyfikatorow.', $expectedSize));
        }

        if (count(array_unique($entryIds)) !== $expectedSize) {
            throw new \InvalidArgumentException('Identyfikatory w skladzie musza byc rozne.');
        }
    }
```

Dopisz import `use App\League\MatchFormat;` — plik jest już w tej przestrzeni nazw, więc import jest zbędny; usuń tę linię, jeśli edytor ją doda.

Usuń metodę `createOpponentSquad` — zastępuje ją `createSideSquad`.

- [ ] **Step 9: Napraw wywołania**

W `src/Controller/FixtureController.php` linia 51 zamień warunek:

```php
        if (null === $mySquad || !$mySquad->isComplete(MatchFormat::Squad4->squadSize())) {
```

i dopisz import `use App\League\MatchFormat;`.

W `tryCreate()` zamień wywołanie:

```php
                $opponentSquad = $squadManager->createSideSquad((string) $model->opponentName, false, $opponentIds, MatchFormat::Squad4->squadSize());
```

Przeszukaj resztę kodu i testów: `grep -rn "createOpponentSquad\|Squad::SIZE\|isComplete()" src/ tests/ templates/` i popraw każde trafienie.

- [ ] **Step 10: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 11: Commit**

```bash
git add src/League/MatchFormat.php src/Entity/Squad.php src/League/SquadManager.php src/Controller/FixtureController.php tests/
git commit -m "feat: format meczu i rozmiar skladu zalezny od formatu"
```

---

### Task 2: Format na meczu, encja pojedynku i migracja

**Files:**
- Modify: `src/Entity/LeagueFixture.php`
- Create: `src/Entity/Duel.php`
- Create: `migrations/Version20260822120000.php`
- Modify: `tests/Support/EntityFactory.php`
- Test: `tests/Integration/LeagueFixtureTest.php` (nowy)

**Interfaces:**
- Consumes: `App\League\MatchFormat` z Task 1.
- Produces:
  - `App\Entity\LeagueFixture::__construct(Gameweek $gameweek, Squad $mySquad, Squad $opponentSquad, MatchFormat $format, \DateTimeImmutable $createdAt)`
  - `LeagueFixture::getFormat(): MatchFormat`
  - `LeagueFixture::addDuel(int $slot, FplEntry $mine, FplEntry $opponent): void`
  - `LeagueFixture::getDuels(): list<Duel>` — posortowane po numerze
  - `LeagueFixture::duelPairs(): list<DuelPair>` — **dodane dopiero w Task 5**, tu jeszcze nie istnieje
  - `App\Entity\Duel::getSlot(): int`, `getMyEntry(): FplEntry`, `getOpponentEntry(): FplEntry`
  - `EntityFactory::fixture(Gameweek $gameweek, Squad $mine, Squad $opponent, MatchFormat $format = MatchFormat::Squad4): LeagueFixture`

- [ ] **Step 1: Napisz padający test encji**

Utwórz `tests/Integration/LeagueFixtureTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Entity\LeagueFixture;
use App\League\MatchFormat;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class LeagueFixtureTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private EntityFactory $factory;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->factory = new EntityFactory($this->em);
    }

    public function testStoresTheFormatOfTheMatch(): void
    {
        $gameweek = $this->factory->gameweek(1);
        $mine = $this->factory->squad('Moi', [101, 102, 103], true);
        $theirs = $this->factory->squad('Oni', [201, 202, 203], false);
        $fixture = $this->factory->fixture($gameweek, $mine, $theirs, MatchFormat::Trio);
        $this->em->flush();

        $id = $fixture->getId();
        $this->em->clear();

        $loaded = $this->em->find(LeagueFixture::class, $id);

        self::assertNotNull($loaded);
        self::assertSame(MatchFormat::Trio, $loaded->getFormat());
    }

    public function testAllowsSeveralMatchesInOneGameweek(): void
    {
        // Meczow doraznych moze byc w kolejce dowolnie wiele — ograniczenie
        // unikalnosci kolejki znika, a reguly pilnuje formularz.
        $gameweek = $this->factory->gameweek(1);
        $first = $this->factory->squad('Ja A', [101], true);
        $firstOpponent = $this->factory->squad('On A', [201], false);
        $second = $this->factory->squad('Ja B', [102], true);
        $secondOpponent = $this->factory->squad('On B', [202], false);

        $this->factory->fixture($gameweek, $first, $firstOpponent, MatchFormat::Duel);
        $this->factory->fixture($gameweek, $second, $secondOpponent, MatchFormat::Duel);

        $this->em->flush();

        self::assertCount(2, $this->em->getRepository(LeagueFixture::class)->findAll());
    }

    public function testKeepsDuelsOrderedBySlot(): void
    {
        $gameweek = $this->factory->gameweek(1);
        $mine = $this->factory->squad('Moi', [101, 102, 103], true);
        $theirs = $this->factory->squad('Oni', [201, 202, 203], false);
        $fixture = $this->factory->fixture($gameweek, $mine, $theirs, MatchFormat::Trio);

        $myEntries = $mine->entries();
        $theirEntries = $theirs->entries();
        $fixture->addDuel(3, $myEntries[2], $theirEntries[0]);
        $fixture->addDuel(1, $myEntries[0], $theirEntries[1]);
        $fixture->addDuel(2, $myEntries[1], $theirEntries[2]);
        $this->em->flush();

        $id = $fixture->getId();
        $this->em->clear();
        $loaded = $this->em->find(LeagueFixture::class, $id);

        self::assertNotNull($loaded);
        $slots = array_map(static fn ($duel) => $duel->getSlot(), $loaded->getDuels());
        self::assertSame([1, 2, 3], $slots);
    }

    public function testRejectsDuelSlotOutsideOneToThree(): void
    {
        $gameweek = $this->factory->gameweek(1);
        $mine = $this->factory->squad('Moi', [101, 102, 103], true);
        $theirs = $this->factory->squad('Oni', [201, 202, 203], false);
        $fixture = $this->factory->fixture($gameweek, $mine, $theirs, MatchFormat::Trio);

        $this->expectException(\InvalidArgumentException::class);

        $fixture->addDuel(4, $mine->entries()[0], $theirs->entries()[0]);
    }
}
```

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter LeagueFixtureTest`
Expected: FAIL — `Too few arguments to function App\Entity\LeagueFixture::__construct()`

- [ ] **Step 3: Dodaj format i pojedynki do encji meczu**

W `src/Entity/LeagueFixture.php` zdejmij ograniczenie unikalności i dodaj kolumnę formatu oraz kolekcję pojedynków. Nagłówek klasy:

```php
#[ORM\Entity(repositoryClass: LeagueFixtureRepository::class)]
#[ORM\Table(name: 'league_fixture')]
class LeagueFixture
{
```

Pola po `$opponentSquad`:

```php
    #[ORM\Column(length: 10, enumType: MatchFormat::class)]
    private MatchFormat $format;

    /** @var Collection<int, Duel> */
    #[ORM\OneToMany(mappedBy: 'fixture', targetEntity: Duel::class, cascade: ['persist'], orphanRemoval: true)]
    #[ORM\OrderBy(['slot' => 'ASC'])]
    private Collection $duels;
```

Konstruktor:

```php
    public function __construct(Gameweek $gameweek, Squad $mySquad, Squad $opponentSquad, MatchFormat $format, \DateTimeImmutable $createdAt)
    {
        $this->gameweek = $gameweek;
        $this->mySquad = $mySquad;
        $this->opponentSquad = $opponentSquad;
        $this->format = $format;
        $this->createdAt = $createdAt;
        $this->duels = new ArrayCollection();
    }
```

Metody:

```php
    public function getFormat(): MatchFormat
    {
        return $this->format;
    }

    public function addDuel(int $slot, FplEntry $mine, FplEntry $opponent): void
    {
        if ($slot < 1 || $slot > 3) {
            throw new \InvalidArgumentException(sprintf('Numer pojedynku musi byc z zakresu 1-3, podano %d.', $slot));
        }

        $this->duels->add(new Duel($this, $slot, $mine, $opponent));
    }

    /** @return list<Duel> */
    public function getDuels(): array
    {
        return array_values($this->duels->toArray());
    }
```

Dopisz importy: `use App\League\MatchFormat;`, `use Doctrine\Common\Collections\ArrayCollection;`, `use Doctrine\Common\Collections\Collection;`.

- [ ] **Step 4: Napisz encję pojedynku**

Utwórz `src/Entity/Duel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Pojedynek w trojkach. Pary ustala sie z rywalem przed kolejka, wiec nie
 * wynikaja z kolejnosci skladu — kazda jest zapisana wprost.
 */
#[ORM\Entity]
#[ORM\UniqueConstraint(name: 'uniq_duel_slot', columns: ['fixture_id', 'slot'])]
class Duel
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: LeagueFixture::class, inversedBy: 'duels')]
    #[ORM\JoinColumn(nullable: false)]
    private LeagueFixture $fixture;

    #[ORM\Column(type: 'smallint')]
    private int $slot;

    #[ORM\ManyToOne(targetEntity: FplEntry::class)]
    #[ORM\JoinColumn(nullable: false)]
    private FplEntry $myEntry;

    #[ORM\ManyToOne(targetEntity: FplEntry::class)]
    #[ORM\JoinColumn(nullable: false)]
    private FplEntry $opponentEntry;

    public function __construct(LeagueFixture $fixture, int $slot, FplEntry $myEntry, FplEntry $opponentEntry)
    {
        $this->fixture = $fixture;
        $this->slot = $slot;
        $this->myEntry = $myEntry;
        $this->opponentEntry = $opponentEntry;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getSlot(): int
    {
        return $this->slot;
    }

    public function getMyEntry(): FplEntry
    {
        return $this->myEntry;
    }

    public function getOpponentEntry(): FplEntry
    {
        return $this->opponentEntry;
    }
}
```

- [ ] **Step 5: Zaktualizuj fabrykę testową**

W `tests/Support/EntityFactory.php` zamień metody `squad` i `fixture`:

```php
    /** @param list<int> $entryIds */
    public function squad(string $name, array $entryIds, bool $mine): Squad
    {
        $squad = new Squad($name, $mine, new \DateTimeImmutable('2026-08-16 12:00:00'));
        foreach ($entryIds as $slot => $entryId) {
            $squad->setMember($slot + 1, $this->em->find(FplEntry::class, $entryId) ?? $this->entry($entryId));
        }
        $this->em->persist($squad);

        return $squad;
    }

    public function fixture(Gameweek $gameweek, Squad $mine, Squad $opponent, MatchFormat $format = MatchFormat::Squad4): LeagueFixture
    {
        $fixture = new LeagueFixture($gameweek, $mine, $opponent, $format, new \DateTimeImmutable('2026-08-16 12:00:00'));
        $this->em->persist($fixture);

        return $fixture;
    }
```

Dopisz import `use App\League\MatchFormat;`.

- [ ] **Step 6: Napisz migrację**

Utwórz `migrations/Version20260822120000.php`:

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260822120000 extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Format meczu, tabela pojedynkow i zdjecie unikalnosci kolejki.';
    }

    /**
     * Kolumna formatu wchodzi z wartoscia domyslna "four", zeby istniejace mecze
     * — wszystkie w formacie 4x4 — nie wymagaly osobnego przepisania. Domyslna
     * wartosc znika zaraz po wypelnieniu, bo format nowego meczu ma podawac kod,
     * a nie baza.
     */
    public function up(Schema $schema): void
    {
        $this->addSql("ALTER TABLE league_fixture ADD format VARCHAR(10) DEFAULT 'four' NOT NULL");
        $this->addSql('ALTER TABLE league_fixture ALTER format DROP DEFAULT');
        $this->addSql('DROP INDEX uniq_fixture_gameweek ON league_fixture');

        $this->addSql('CREATE TABLE duel (
            id INT AUTO_INCREMENT NOT NULL,
            fixture_id INT NOT NULL,
            my_entry_id INT NOT NULL,
            opponent_entry_id INT NOT NULL,
            slot SMALLINT NOT NULL,
            UNIQUE INDEX uniq_duel_slot (fixture_id, slot),
            INDEX idx_duel_fixture (fixture_id),
            PRIMARY KEY(id)
        ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');

        $this->addSql('ALTER TABLE duel ADD CONSTRAINT fk_duel_fixture FOREIGN KEY (fixture_id) REFERENCES league_fixture (id)');
        $this->addSql('ALTER TABLE duel ADD CONSTRAINT fk_duel_my_entry FOREIGN KEY (my_entry_id) REFERENCES fpl_entry (id)');
        $this->addSql('ALTER TABLE duel ADD CONSTRAINT fk_duel_opponent_entry FOREIGN KEY (opponent_entry_id) REFERENCES fpl_entry (id)');
    }

    public function down(Schema $schema): void
    {
        $this->addSql('DROP TABLE duel');
        $this->addSql('ALTER TABLE league_fixture DROP format');
        $this->addSql('CREATE UNIQUE INDEX uniq_fixture_gameweek ON league_fixture (gameweek_id)');
    }
}
```

- [ ] **Step 7: Zapisz stan sprzed migracji**

Baza deweloperska ma rozegraną pierwszą kolejkę. Zanotuj jej wynik, żeby po migracji było z czym porównać:

```bash
php bin/console dbal:run-sql --force-fetch "SELECT f.id, f.gameweek_id, a.name AS moja, b.name AS rywal FROM league_fixture f JOIN squad a ON a.id = f.my_squad_id JOIN squad b ON b.id = f.opponent_squad_id"
php bin/console dbal:run-sql --force-fetch "SELECT e.team_name, g.net_points FROM entry_gameweek g JOIN fpl_entry e ON e.id = g.fpl_entry_id WHERE g.gameweek_id = 1 ORDER BY g.net_points DESC"
```

Zapisz oba wyniki — wrócisz do nich w kroku 9.

- [ ] **Step 8: Uruchom migrację na bazie testowej i deweloperskiej**

Run:
```bash
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console doctrine:schema:validate
```
Expected: migracja przechodzi, walidacja mapowania zgłasza zgodność schematu z encjami.

Jeśli `schema:validate` zgłasza różnicę, popraw migrację tak, żeby odpowiadała mapowaniu — nie odwrotnie.

- [ ] **Step 9: Potwierdź, że migracja nie ruszyła danych**

Run:
```bash
php bin/console dbal:run-sql --force-fetch "SELECT id, gameweek_id, format FROM league_fixture"
php bin/console dbal:run-sql --force-fetch "SELECT e.team_name, g.net_points FROM entry_gameweek g JOIN fpl_entry e ON e.id = g.fpl_entry_id WHERE g.gameweek_id = 1 ORDER BY g.net_points DESC"
```
Expected: te same mecze co w kroku 7, każdy z formatem `four`; dorobki drużyn identyczne.

Jeśli którakolwiek liczba się różni, wycofaj migrację (`doctrine:migrations:migrate prev`) i znajdź przyczynę, zanim pójdziesz dalej.

- [ ] **Step 10: Napraw wywołania konstruktora meczu**

Run: `grep -rn "new LeagueFixture(" src/ tests/`
Każde wywołanie dostaje czwarty argument — format. W `FixtureController::tryCreate()` na razie wstaw `MatchFormat::Squad4` (Task 9 zamieni to na format z formularza).

- [ ] **Step 11: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 12: Commit**

```bash
git add src/Entity/ migrations/ tests/
git commit -m "feat: format na meczu, encja pojedynku i migracja"
```

---

### Task 3: Kontrakt liczenia wyniku i rejestr

**Files:**
- Create: `src/League/MatchScorer.php`
- Create: `src/League/MatchScorers.php`
- Modify: `src/League/LeagueScorer.php`
- Modify: `src/League/MatchResult.php`
- Create: `tests/Unit/League/MatchScorersTest.php`

**Interfaces:**
- Consumes: `App\League\MatchFormat` (Task 1).
- Produces:
  - `interface App\League\MatchScorer { public function score(array $inputs, array $duelPairs = []): MatchResult; }` — `$inputs` to `list<ScoreInput>`, `$duelPairs` to `list<DuelPair>` (typ powstaje w Task 5; do tego czasu tablica jest pusta).
  - `App\League\MatchScorers::for(MatchFormat $format): MatchScorer`
  - `App\League\MatchResult::__construct(array $rows, float $myPoints, float $opponentPoints, array $duels = [])` — `$duels` to `list<DuelResult>`, domyślnie puste.
  - `MatchResult::$duels` (publiczne, tylko do odczytu).

- [ ] **Step 1: Napisz padający test rejestru**

Utwórz `tests/Unit/League/MatchScorersTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\League\LeagueScorer;
use App\League\MatchFormat;
use App\League\MatchScorers;
use PHPUnit\Framework\TestCase;

final class MatchScorersTest extends TestCase
{
    public function testFourAgainstFourGoesToThePlacesScorer(): void
    {
        $registry = new MatchScorers(new LeagueScorer(), new \App\League\SumScorer(), new \App\League\DuelsScorer());

        self::assertInstanceOf(LeagueScorer::class, $registry->for(MatchFormat::Squad4));
    }

    public function testDuelAndDuoGoToTheSumScorer(): void
    {
        $registry = new MatchScorers(new LeagueScorer(), new \App\League\SumScorer(), new \App\League\DuelsScorer());

        self::assertInstanceOf(\App\League\SumScorer::class, $registry->for(MatchFormat::Duel));
        self::assertInstanceOf(\App\League\SumScorer::class, $registry->for(MatchFormat::Duo));
    }

    public function testTrioGoesToTheDuelsScorer(): void
    {
        $registry = new MatchScorers(new LeagueScorer(), new \App\League\SumScorer(), new \App\League\DuelsScorer());

        self::assertInstanceOf(\App\League\DuelsScorer::class, $registry->for(MatchFormat::Trio));
    }
}
```

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter MatchScorersTest`
Expected: FAIL — `Class "App\League\MatchScorers" not found`

- [ ] **Step 3: Napisz kontrakt**

Utwórz `src/League/MatchScorer.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/**
 * Wspolny kontrakt liczenia wyniku meczu. Kazdy format ma wlasna implementacje —
 * miejsca w 4x4, suma dorobku w pojedynku i duetach, liczba wygranych pojedynkow
 * w trojkach.
 */
interface MatchScorer
{
    /**
     * @param list<ScoreInput> $inputs
     * @param list<DuelPair>   $duelPairs pary do rozegrania; puste poza trojkami
     */
    public function score(array $inputs, array $duelPairs = []): MatchResult;
}
```

- [ ] **Step 4: Napisz rejestr**

Utwórz `src/League/MatchScorers.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class MatchScorers
{
    public function __construct(
        private LeagueScorer $places,
        private SumScorer $sum,
        private DuelsScorer $duels,
    ) {
    }

    public function for(MatchFormat $format): MatchScorer
    {
        return match ($format) {
            MatchFormat::Squad4 => $this->places,
            MatchFormat::Duel, MatchFormat::Duo => $this->sum,
            MatchFormat::Trio => $this->duels,
        };
    }
}
```

- [ ] **Step 5: Podepnij LeagueScorer pod kontrakt**

W `src/League/LeagueScorer.php` zmień nagłówek klasy i sygnaturę:

```php
final class LeagueScorer implements MatchScorer
{
    public const SQUAD_SIZE = 4;
    public const ENTRIES_PER_MATCH = 8;
    public const TOTAL_LEAGUE_POINTS = 28.0;

    /**
     * @param list<ScoreInput> $inputs
     * @param list<DuelPair>   $duelPairs nieuzywane w tym formacie
     */
    public function score(array $inputs, array $duelPairs = []): MatchResult
    {
```

Reszta ciała bez zmian.

- [ ] **Step 6: Dodaj pojedynki do wyniku meczu**

W `src/League/MatchResult.php` rozszerz konstruktor:

```php
    /**
     * @param list<StandingRow> $rows
     * @param list<DuelResult>  $duels rozstrzygniete pojedynki; puste poza trojkami
     */
    public function __construct(
        public array $rows,
        public float $myPoints,
        public float $opponentPoints,
        public array $duels = [],
    ) {
    }
```

- [ ] **Step 7: Uruchom test — nadal pada na brakujących klasach**

Run: `vendor/bin/phpunit --filter MatchScorersTest`
Expected: FAIL — `Class "App\League\SumScorer" not found`

To oczekiwane: `SumScorer` powstaje w Task 4, `DuelsScorer` w Task 5. Rejestr i kontrakt są gotowe.

- [ ] **Step 8: Uruchom resztę suity**

Run: `vendor/bin/phpunit --exclude-filter MatchScorersTest`
Expected: PASS

- [ ] **Step 9: Commit**

```bash
git add src/League/MatchScorer.php src/League/MatchScorers.php src/League/LeagueScorer.php src/League/MatchResult.php tests/Unit/League/MatchScorersTest.php
git commit -m "feat: kontrakt liczenia wyniku i rejestr implementacji"
```

Uwaga: ten commit zostawia `MatchScorersTest` padający. Task 4 i 5 domykają go. Jeśli reguły projektu zabraniają commitować z padającym testem, wykonaj Task 3, 4 i 5 jako jeden commit na końcu Task 5.

---

### Task 4: Wynik z sumy dorobku — pojedynek i duety

**Files:**
- Create: `src/League/SumScorer.php`
- Create: `tests/Unit/League/SumScorerTest.php`

**Interfaces:**
- Consumes: `MatchScorer`, `MatchResult`, `ScoreInput`, `StandingRow`, `MatchSide` (Task 3 i stan obecny).
- Produces: `App\League\SumScorer implements MatchScorer`.

Zasada: obie strony muszą mieć tyle samo drużyn, co najmniej jedną. Liczba dla strony to suma `netPoints` jej drużyn. Wiersze dostają pozycję z uporządkowania malejąco po `netPoints`, remisy dzielą tę samą pozycję. `leaguePoints` w wierszu to `0.0` — ten format nie zna punktów ligowych.

- [ ] **Step 1: Napisz padające testy**

Utwórz `tests/Unit/League/SumScorerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\League\MatchOutcome;
use App\League\MatchSide;
use App\League\ScoreInput;
use App\League\SumScorer;
use PHPUnit\Framework\TestCase;

final class SumScorerTest extends TestCase
{
    private function input(int $id, MatchSide $side, int $points): ScoreInput
    {
        return new ScoreInput($id, 'Druzyna '.$id, $side, $points);
    }

    public function testSingleDuelIsWonByTheHigherHaul(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 61),
            $this->input(2, MatchSide::Opponent, 48),
        ]);

        self::assertSame(61.0, $result->myPoints);
        self::assertSame(48.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Win, $result->outcome());
        self::assertSame(13.0, $result->margin());
    }

    public function testEqualHaulsInADuelAreADraw(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 55),
            $this->input(2, MatchSide::Opponent, 55),
        ]);

        self::assertSame(MatchOutcome::Draw, $result->outcome());
        self::assertSame(0.0, $result->margin());
    }

    public function testDuoIsDecidedByTheSumOfThePair(): void
    {
        // Moja para: 30 + 44 = 74. Ich: 51 + 20 = 71. Wyzsza druzyna po ich
        // stronie nie wystarcza, bo liczy sie suma.
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Mine, 44),
            $this->input(3, MatchSide::Opponent, 51),
            $this->input(4, MatchSide::Opponent, 20),
        ]);

        self::assertSame(74.0, $result->myPoints);
        self::assertSame(71.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Win, $result->outcome());
    }

    public function testEqualSumsInADuoAreADraw(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Mine, 40),
            $this->input(3, MatchSide::Opponent, 35),
            $this->input(4, MatchSide::Opponent, 35),
        ]);

        self::assertSame(MatchOutcome::Draw, $result->outcome());
    }

    public function testRowsAreRankedByHaulWithSharedPositionsOnTies(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Mine, 51),
            $this->input(3, MatchSide::Opponent, 51),
            $this->input(4, MatchSide::Opponent, 20),
        ]);

        $positions = [];
        foreach ($result->rows as $row) {
            $positions[$row->input->fplEntryId] = $row->position;
        }

        self::assertSame(1, $positions[2]);
        self::assertSame(1, $positions[3], 'Rowny dorobek dzieli te sama pozycje.');
        self::assertSame(3, $positions[1]);
        self::assertSame(4, $positions[4]);
    }

    public function testRowsCarryNoLeaguePoints(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Opponent, 20),
        ]);

        foreach ($result->rows as $row) {
            self::assertSame(0.0, $row->leaguePoints, 'Punkty ligowe istnieja wylacznie w 4x4.');
        }
    }

    public function testResultCarriesNoDuels(): void
    {
        $result = (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Opponent, 20),
        ]);

        self::assertSame([], $result->duels);
    }

    public function testRejectsUnevenSides(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
            $this->input(2, MatchSide::Mine, 30),
            $this->input(3, MatchSide::Opponent, 20),
        ]);
    }

    public function testRejectsEmptySide(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new SumScorer())->score([
            $this->input(1, MatchSide::Mine, 30),
        ]);
    }
}
```

- [ ] **Step 2: Uruchom testy, potwierdź że padają**

Run: `vendor/bin/phpunit --filter SumScorerTest`
Expected: FAIL — `Class "App\League\SumScorer" not found`

- [ ] **Step 3: Napisz implementację**

Utwórz `src/League/SumScorer.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/**
 * Wynik z sumy dorobku obu stron. Obsluguje pojedynek (jedna druzyna na strone)
 * i duety (dwie), bo regula jest ta sama — rozni je wylacznie liczba druzyn.
 */
final class SumScorer implements MatchScorer
{
    /**
     * @param list<ScoreInput> $inputs
     * @param list<DuelPair>   $duelPairs nieuzywane w tym formacie
     */
    public function score(array $inputs, array $duelPairs = []): MatchResult
    {
        $this->assertBalancedSides($inputs);

        $myPoints = 0.0;
        $opponentPoints = 0.0;

        foreach ($inputs as $input) {
            if (MatchSide::Mine === $input->side) {
                $myPoints += $input->netPoints;
            } else {
                $opponentPoints += $input->netPoints;
            }
        }

        return new MatchResult($this->rank($inputs), $myPoints, $opponentPoints);
    }

    /**
     * Pozycje sa wylacznie informacyjne — w tym formacie nic z nich nie wynika,
     * ale tabela meczu pokazuje je tak samo jak w 4x4, wiec musza byc spojne:
     * rowny dorobek dzieli te sama pozycje, a nastepna przeskakuje o tyle miejsc,
     * ile bylo remisujacych.
     *
     * @param list<ScoreInput> $inputs
     *
     * @return list<StandingRow>
     */
    private function rank(array $inputs): array
    {
        $sorted = $inputs;
        usort($sorted, static fn (ScoreInput $a, ScoreInput $b) => $b->netPoints <=> $a->netPoints);

        $rows = [];
        $index = 0;
        $count = count($sorted);

        while ($index < $count) {
            $last = $index;
            while ($last + 1 < $count && $sorted[$last + 1]->netPoints === $sorted[$index]->netPoints) {
                ++$last;
            }

            for ($i = $index; $i <= $last; ++$i) {
                $rows[] = new StandingRow($sorted[$i], $index + 1, 0.0);
            }

            $index = $last + 1;
        }

        return $rows;
    }

    /** @param list<ScoreInput> $inputs */
    private function assertBalancedSides(array $inputs): void
    {
        $mine = 0;
        $opponent = 0;

        foreach ($inputs as $input) {
            if (MatchSide::Mine === $input->side) {
                ++$mine;
            } else {
                ++$opponent;
            }
        }

        if (0 === $mine || 0 === $opponent) {
            throw new \InvalidArgumentException('Mecz wymaga druzyn po obu stronach.');
        }

        if ($mine !== $opponent) {
            throw new \InvalidArgumentException(sprintf('Strony musza miec tyle samo druzyn, jest %d i %d.', $mine, $opponent));
        }
    }
}
```

- [ ] **Step 4: Uruchom testy, potwierdź że przechodzą**

Run: `vendor/bin/phpunit --filter SumScorerTest`
Expected: PASS (9 testów)

- [ ] **Step 5: Commit**

```bash
git add src/League/SumScorer.php tests/Unit/League/SumScorerTest.php
git commit -m "feat: wynik z sumy dorobku dla pojedynku i duetow"
```

---

### Task 5: Wynik z pojedynków — trójki

**Files:**
- Create: `src/League/DuelPair.php`
- Create: `src/League/DuelResult.php`
- Create: `src/League/DuelsScorer.php`
- Modify: `src/Entity/LeagueFixture.php`
- Create: `tests/Unit/League/DuelsScorerTest.php`

**Interfaces:**
- Consumes: `MatchScorer`, `MatchResult` z polem `duels` (Task 3), `ScoreInput`, `MatchSide`, `MatchOutcome`.
- Produces:
  - `App\League\DuelPair` — konstruktor `(int $myEntryId, int $opponentEntryId)`, pola publiczne o tych nazwach.
  - `App\League\DuelResult` — konstruktor `(int $slot, ScoreInput $mine, ScoreInput $opponent, MatchOutcome $outcome)`, pola publiczne `slot`, `mine`, `opponent`, `outcome`.
  - `App\League\DuelsScorer implements MatchScorer`.
  - `App\Entity\LeagueFixture::duelPairs(): list<DuelPair>`.

Zasada: dokładnie trzy pary, sześć drużyn (trzy na stronę). Pojedynek wygrywa wyższy dorobek; równy dorobek to remis pojedynku i nie liczy się nikomu. Liczba dla strony to liczba jej wygranych pojedynków.

- [ ] **Step 1: Napisz padające testy**

Utwórz `tests/Unit/League/DuelsScorerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\League\DuelPair;
use App\League\DuelsScorer;
use App\League\MatchOutcome;
use App\League\MatchSide;
use App\League\ScoreInput;
use PHPUnit\Framework\TestCase;

final class DuelsScorerTest extends TestCase
{
    /** @return list<ScoreInput> */
    private function squads(int $m1, int $m2, int $m3, int $o1, int $o2, int $o3): array
    {
        return [
            new ScoreInput(1, 'Moja 1', MatchSide::Mine, $m1),
            new ScoreInput(2, 'Moja 2', MatchSide::Mine, $m2),
            new ScoreInput(3, 'Moja 3', MatchSide::Mine, $m3),
            new ScoreInput(11, 'Ich 1', MatchSide::Opponent, $o1),
            new ScoreInput(12, 'Ich 2', MatchSide::Opponent, $o2),
            new ScoreInput(13, 'Ich 3', MatchSide::Opponent, $o3),
        ];
    }

    /** @return list<DuelPair> */
    private function straightPairs(): array
    {
        return [new DuelPair(1, 11), new DuelPair(2, 12), new DuelPair(3, 13)];
    }

    public function testWinningTwoDuelsWinsTheMatch(): void
    {
        $result = (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            $this->straightPairs(),
        );

        self::assertSame(2.0, $result->myPoints);
        self::assertSame(1.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Win, $result->outcome());
    }

    public function testEqualHaulsInADuelCountForNeitherSide(): void
    {
        // 60:50 moje, 40:40 nierozstrzygniety, 30:55 ich. Wynik 1-1, czyli remis.
        $result = (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 40, 55),
            $this->straightPairs(),
        );

        self::assertSame(1.0, $result->myPoints);
        self::assertSame(1.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Draw, $result->outcome());
    }

    public function testAllThreeDuelsDrawnIsADraw(): void
    {
        $result = (new DuelsScorer())->score(
            $this->squads(50, 40, 30, 50, 40, 30),
            $this->straightPairs(),
        );

        self::assertSame(0.0, $result->myPoints);
        self::assertSame(0.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Draw, $result->outcome());
    }

    public function testSweepingAllThreeDuelsWinsThreeNil(): void
    {
        $result = (new DuelsScorer())->score(
            $this->squads(60, 50, 40, 10, 20, 30),
            $this->straightPairs(),
        );

        self::assertSame(3.0, $result->myPoints);
        self::assertSame(0.0, $result->opponentPoints);
        self::assertSame(3.0, $result->margin());
    }

    public function testPairingsDecideWhoMeetsWhom(): void
    {
        // Te same dorobki co w tescie 2-1, ale pary poprzestawiane: moja 1 (60)
        // trafia na ich 2 (55), moja 2 (40) na ich 3 (20), moja 3 (30) na ich 1 (50).
        $result = (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            [new DuelPair(1, 12), new DuelPair(2, 13), new DuelPair(3, 11)],
        );

        self::assertSame(2.0, $result->myPoints);
        self::assertSame(1.0, $result->opponentPoints);
    }

    public function testResultCarriesEveryDuelWithItsOutcome(): void
    {
        $result = (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 40, 55),
            $this->straightPairs(),
        );

        self::assertCount(3, $result->duels);
        self::assertSame([1, 2, 3], array_map(static fn ($d) => $d->slot, $result->duels));
        self::assertSame(MatchOutcome::Win, $result->duels[0]->outcome);
        self::assertSame(MatchOutcome::Draw, $result->duels[1]->outcome);
        self::assertSame(MatchOutcome::Loss, $result->duels[2]->outcome);
        self::assertSame(1, $result->duels[0]->mine->fplEntryId);
        self::assertSame(11, $result->duels[0]->opponent->fplEntryId);
    }

    public function testRejectsWrongNumberOfPairs(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            [new DuelPair(1, 11), new DuelPair(2, 12)],
        );
    }

    public function testRejectsPairPointingAtAnEntryOutsideTheMatch(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            [new DuelPair(1, 11), new DuelPair(2, 12), new DuelPair(3, 99)],
        );
    }

    public function testRejectsPairThatPutsTwoOfMyEntriesAgainstEachOther(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            [new DuelPair(1, 2), new DuelPair(2, 12), new DuelPair(3, 13)],
        );
    }

    public function testRejectsEntryUsedInTwoPairs(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new DuelsScorer())->score(
            $this->squads(60, 40, 30, 50, 55, 20),
            [new DuelPair(1, 11), new DuelPair(1, 12), new DuelPair(3, 13)],
        );
    }

    public function testRejectsSquadsOtherThanThreeOnThree(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new DuelsScorer())->score([
            new ScoreInput(1, 'Moja 1', MatchSide::Mine, 50),
            new ScoreInput(11, 'Ich 1', MatchSide::Opponent, 40),
        ], $this->straightPairs());
    }
}
```

- [ ] **Step 2: Uruchom testy, potwierdź że padają**

Run: `vendor/bin/phpunit --filter DuelsScorerTest`
Expected: FAIL — `Class "App\League\DuelPair" not found`

- [ ] **Step 3: Napisz obiekty pary i rozstrzygnięcia**

Utwórz `src/League/DuelPair.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/** Para do rozegrania w trojkach: moja druzyna kontra ich. */
final readonly class DuelPair
{
    public function __construct(
        public int $myEntryId,
        public int $opponentEntryId,
    ) {
    }
}
```

Utwórz `src/League/DuelResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/** Rozstrzygniety pojedynek. Rozstrzygniecie jest zawsze z mojej perspektywy. */
final readonly class DuelResult
{
    public function __construct(
        public int $slot,
        public ScoreInput $mine,
        public ScoreInput $opponent,
        public MatchOutcome $outcome,
    ) {
    }
}
```

- [ ] **Step 4: Napisz implementację**

Utwórz `src/League/DuelsScorer.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

/**
 * Wynik trojek: trzy niezalezne pojedynki, mecz wygrywa strona z wieksza liczba
 * wygranych. Pojedynek o rownym dorobku przepada obu stronom — nie liczy sie
 * nikomu, wiec 1-1 przy jednym nierozstrzygnietym jest remisem meczu.
 */
final class DuelsScorer implements MatchScorer
{
    private const DUELS = 3;

    /**
     * @param list<ScoreInput> $inputs
     * @param list<DuelPair>   $duelPairs
     */
    public function score(array $inputs, array $duelPairs = []): MatchResult
    {
        $byId = $this->indexInputs($inputs);
        $this->assertPairsCoverTheSquads($byId, $duelPairs);

        $duels = [];
        $myWins = 0.0;
        $opponentWins = 0.0;

        foreach ($duelPairs as $index => $pair) {
            $mine = $byId[$pair->myEntryId];
            $opponent = $byId[$pair->opponentEntryId];

            $outcome = match (true) {
                $mine->netPoints > $opponent->netPoints => MatchOutcome::Win,
                $mine->netPoints < $opponent->netPoints => MatchOutcome::Loss,
                default => MatchOutcome::Draw,
            };

            if (MatchOutcome::Win === $outcome) {
                ++$myWins;
            } elseif (MatchOutcome::Loss === $outcome) {
                ++$opponentWins;
            }

            $duels[] = new DuelResult($index + 1, $mine, $opponent, $outcome);
        }

        return new MatchResult($this->rank($inputs), $myWins, $opponentWins, $duels);
    }

    /**
     * @param list<ScoreInput> $inputs
     *
     * @return array<int, ScoreInput>
     */
    private function indexInputs(array $inputs): array
    {
        $mine = 0;
        $opponent = 0;
        $byId = [];

        foreach ($inputs as $input) {
            $byId[$input->fplEntryId] = $input;

            if (MatchSide::Mine === $input->side) {
                ++$mine;
            } else {
                ++$opponent;
            }
        }

        if (self::DUELS !== $mine || self::DUELS !== $opponent) {
            throw new \InvalidArgumentException(sprintf('Trojki wymagaja %d druzyn po stronie, jest %d i %d.', self::DUELS, $mine, $opponent));
        }

        return $byId;
    }

    /**
     * @param array<int, ScoreInput> $byId
     * @param list<DuelPair>         $pairs
     */
    private function assertPairsCoverTheSquads(array $byId, array $pairs): void
    {
        if (self::DUELS !== count($pairs)) {
            throw new \InvalidArgumentException(sprintf('Trojki wymagaja %d par, podano %d.', self::DUELS, count($pairs)));
        }

        $usedMine = [];
        $usedOpponent = [];

        foreach ($pairs as $pair) {
            $mine = $byId[$pair->myEntryId] ?? throw new \InvalidArgumentException(sprintf('Para wskazuje druzyne %d spoza meczu.', $pair->myEntryId));
            $opponent = $byId[$pair->opponentEntryId] ?? throw new \InvalidArgumentException(sprintf('Para wskazuje druzyne %d spoza meczu.', $pair->opponentEntryId));

            if (MatchSide::Mine !== $mine->side || MatchSide::Opponent !== $opponent->side) {
                throw new \InvalidArgumentException('Pojedynek musi laczyc moja druzyne z druzyna rywala.');
            }

            $usedMine[$pair->myEntryId] = true;
            $usedOpponent[$pair->opponentEntryId] = true;
        }

        if (self::DUELS !== count($usedMine) || self::DUELS !== count($usedOpponent)) {
            throw new \InvalidArgumentException('Kazda druzyna musi wystapic w dokladnie jednym pojedynku.');
        }
    }

    /**
     * Tabela meczu pokazuje pozycje tak samo jak w pozostalych formatach, choc w
     * trojkach o wyniku decyduja pojedynki, a nie miejsce w tabeli.
     *
     * @param list<ScoreInput> $inputs
     *
     * @return list<StandingRow>
     */
    private function rank(array $inputs): array
    {
        $sorted = $inputs;
        usort($sorted, static fn (ScoreInput $a, ScoreInput $b) => $b->netPoints <=> $a->netPoints);

        $rows = [];
        $index = 0;
        $count = count($sorted);

        while ($index < $count) {
            $last = $index;
            while ($last + 1 < $count && $sorted[$last + 1]->netPoints === $sorted[$index]->netPoints) {
                ++$last;
            }

            for ($i = $index; $i <= $last; ++$i) {
                $rows[] = new StandingRow($sorted[$i], $index + 1, 0.0);
            }

            $index = $last + 1;
        }

        return $rows;
    }
}
```

- [ ] **Step 5: Dodaj pary do encji meczu**

W `src/Entity/LeagueFixture.php` dopisz metodę pod `getDuels()`:

```php
    /**
     * Pary w postaci, ktorej oczekuje liczenie wyniku — encje pojedynkow niosa
     * druzyny, a liczenie operuje na identyfikatorach.
     *
     * @return list<DuelPair>
     */
    public function duelPairs(): array
    {
        return array_map(
            static fn (Duel $duel): DuelPair => new DuelPair($duel->getMyEntry()->getId(), $duel->getOpponentEntry()->getId()),
            $this->getDuels(),
        );
    }
```

Dopisz import `use App\League\DuelPair;`.

- [ ] **Step 6: Uruchom testy, potwierdź że przechodzą**

Run: `vendor/bin/phpunit --filter "DuelsScorerTest|MatchScorersTest|LeagueFixtureTest"`
Expected: PASS

- [ ] **Step 7: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 8: Commit**

```bash
git add src/League/DuelPair.php src/League/DuelResult.php src/League/DuelsScorer.php src/Entity/LeagueFixture.php tests/Unit/League/DuelsScorerTest.php
git commit -m "feat: wynik z pojedynkow dla trojek"
```

---

### Task 6: Budowanie widoku meczu według formatu

**Files:**
- Modify: `src/League/MatchViewBuilder.php`
- Modify: `tests/Integration/MatchViewBuilderTest.php`

**Interfaces:**
- Consumes: `MatchScorers::for()` (Task 3), `LeagueFixture::getFormat()` i `duelPairs()` (Task 2 i 5).
- Produces: `MatchViewBuilder::__construct(EntryGameweekSync $sync, EntryGameweekRepository $snapshots, MatchScorers $scorers, DifferentialAnalyzer $analyzer)` — trzeci argument zmienia typ z `LeagueScorer` na `MatchScorers`.

- [ ] **Step 1: Napisz padający test**

Dopisz do `tests/Integration/MatchViewBuilderTest.php`:

```php
    public function testTrioMatchIsScoredByDuels(): void
    {
        // Dorobki ustawione tak, ze suma sprzyja rywalowi, a pojedynki mnie —
        // gdyby budowniczy uzyl niewlasciwej implementacji, wynik bylby odwrotny.
        $gameweek = $this->factory->gameweek(1);
        $mine = $this->factory->squad('Moi', [101, 102, 103], true);
        $theirs = $this->factory->squad('Oni', [201, 202, 203], false);
        $fixture = $this->factory->fixture($gameweek, $mine, $theirs, MatchFormat::Trio);

        $myEntries = $mine->entries();
        $theirEntries = $theirs->entries();
        $fixture->addDuel(1, $myEntries[0], $theirEntries[0]);
        $fixture->addDuel(2, $myEntries[1], $theirEntries[1]);
        $fixture->addDuel(3, $myEntries[2], $theirEntries[2]);

        $this->snapshotWithPoints(101, $gameweek, 51);
        $this->snapshotWithPoints(102, $gameweek, 41);
        $this->snapshotWithPoints(103, $gameweek, 10);
        $this->snapshotWithPoints(201, $gameweek, 50);
        $this->snapshotWithPoints(202, $gameweek, 40);
        $this->snapshotWithPoints(203, $gameweek, 90);
        $this->em->flush();

        // Pusta lista odpowiedzi: build() z refresh=false czyta wylacznie baze,
        // wiec zadne zapytanie do API nie powinno paść.
        $view = $this->builder([])->build($fixture, false);

        self::assertSame(2.0, $view->result->myPoints, 'Dwa wygrane pojedynki.');
        self::assertSame(1.0, $view->result->opponentPoints);
        self::assertCount(3, $view->result->duels);
    }
```

Istniejąca metoda `builder(array $responses)` przyjmuje listę odpowiedzi atrapy HTTP. Metody `snapshotWithPoints` w pliku nie ma — dopisz ją:

```php
    /** Snapshot z gotowym dorobkiem, bez odpytywania API. */
    private function snapshotWithPoints(int $entryId, Gameweek $gameweek, int $netPoints): void
    {
        $entry = $this->em->find(FplEntry::class, $entryId) ?? $this->factory->entry($entryId);
        $snapshot = new EntryGameweek($entry, $gameweek);
        $snapshot->applyResult(
            apiPoints: $netPoints,
            transfers: 0,
            transfersCost: 0,
            pointsOnBench: 0,
            activeChip: null,
            livePointsSum: $netPoints,
            pointsIncludeHit: null,
            netPoints: $netPoints,
            captainPoints: 0,
        );
        $snapshot->markSynced(new \DateTimeImmutable('2026-08-22 12:00:00'), false);
        $this->em->persist($snapshot);
    }
```

Dopisz importy `use App\Entity\EntryGameweek;`, `use App\Entity\FplEntry;`, `use App\Entity\Gameweek;`, `use App\League\MatchFormat;`. Jeśli w klasie nie ma pola `$this->factory` typu `EntityFactory`, utwórz je w `setUp()` tak samo jak w `tests/Integration/EntryGameweekSyncTest.php`.

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter testTrioMatchIsScoredByDuels`
Expected: FAIL — mecz liczony miejscami albo wyjątek o liczbie drużyn

- [ ] **Step 3: Przełącz budowniczego na rejestr**

W `src/League/MatchViewBuilder.php` zamień pole i wywołanie:

```php
    public function __construct(
        private EntryGameweekSync $sync,
        private EntryGameweekRepository $snapshots,
        private MatchScorers $scorers,
        private DifferentialAnalyzer $analyzer,
    ) {
    }
```

```php
        $scorer = $this->scorers->for($fixture->getFormat());

        return new MatchView(
            $fixture,
            $scorer->score($inputs, $fixture->duelPairs()),
            $report,
            $snapshots,
            $this->oldestSyncedAt($snapshots),
            $refresh && $this->sync->lastSyncFailed(),
        );
```

- [ ] **Step 4: Napraw ręczne konstrukcje budowniczego w testach**

Run: `grep -rn "new MatchViewBuilder(" tests/ src/`
W każdym miejscu podmień `new LeagueScorer()` na `new MatchScorers(new LeagueScorer(), new SumScorer(), new DuelsScorer())` i dopisz brakujące importy.

- [ ] **Step 5: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add src/League/MatchViewBuilder.php tests/
git commit -m "feat: widok meczu liczony implementacja wybrana po formacie"
```

---

### Task 7: Strona kolejki ze wszystkimi meczami

**Files:**
- Modify: `src/Repository/LeagueFixtureRepository.php`
- Create: `src/Controller/GameweekController.php`
- Modify: `src/Controller/FixtureController.php` (usunięcie `show()` i `current()`, poprawa przekierowań)
- Create: `templates/gameweek/show.html.twig`
- Create: `templates/fixture/_match.html.twig`
- Delete: `templates/fixture/show.html.twig`
- Modify: `templates/dashboard/index.html.twig`
- Modify: `templates/base.html.twig`
- Modify: `public/css/app.css`
- Modify: `tests/Functional/FixtureShowControllerTest.php` → zmiana nazwy na `tests/Functional/GameweekControllerTest.php`
- Modify: `tests/Functional/FixtureCurrentControllerTest.php`

**Interfaces:**
- Consumes: `MatchViewBuilder::build()` (Task 6), `MatchFormat` (Task 1).
- Produces:
  - `LeagueFixtureRepository::findByGameweek(int $gameweek): list<LeagueFixture>` — najpierw 4x4, potem reszta po identyfikatorze rosnąco
  - `LeagueFixtureRepository::findFourByGameweek(int $gameweek): ?LeagueFixture`
  - `LeagueFixtureRepository::findFourOrdered(): list<LeagueFixture>` — mecze 4x4 całego sezonu po kolejce rosnąco
  - trasa `gameweek_show` pod `/kolejka/{gameweek}`
  - trasa `fixture_current` pod `/teraz` (przeniesiona z `FixtureController`)
  - `templates/fixture/_match.html.twig` oczekuje zmiennych `view` i `fixture`

- [ ] **Step 1: Napisz padające testy strony kolejki**

Zmień nazwę pliku i dostosuj:

```bash
git mv tests/Functional/FixtureShowControllerTest.php tests/Functional/GameweekControllerTest.php
```

W pliku zamień nazwę klasy na `GameweekControllerTest`, wszystkie `'/fixture/1'` na `'/kolejka/1'`, `'/fixture/7'` na `'/kolejka/7'`, a asercję `assertResponseStatusCodeSame(404)` w teście o brakującym meczu zamień na sprawdzenie komunikatu o pustej kolejce. Dopisz nowe testy:

```php
    public function testShowsEveryMatchOfTheGameweek(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);
        $team = $factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $factory->element($elementId, $team);
        }

        $four = $factory->squad('Kuba FC', [101, 102, 103, 104], true);
        $fourOpponent = $factory->squad('Rywale', [201, 202, 203, 204], false);
        $factory->fixture($gameweek, $four, $fourOpponent);

        $duelMine = $factory->squad('Ja sam', [105], true);
        $duelTheirs = $factory->squad('On sam', [205], false);
        $factory->fixture($gameweek, $duelMine, $duelTheirs, MatchFormat::Duel);

        $this->em->flush();

        $crawler = $this->client->request('GET', '/kolejka/1');

        self::assertResponseIsSuccessful();
        self::assertCount(2, $crawler->filter('.match'));
        self::assertSelectorTextContains('h1', 'Kolejka 1');
    }

    public function testEmptyGameweekExplainsItselfInsteadOfFailing(): void
    {
        $factory = new EntityFactory($this->em);
        $factory->gameweek(1);
        $this->em->flush();

        $this->client->request('GET', '/kolejka/1');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('.hint', 'Brak meczow');
    }

    public function testTwoDuelsInOneGameweekBothShowUp(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);

        $factory->fixture($gameweek, $factory->squad('Ja A', [101], true), $factory->squad('On A', [201], false), MatchFormat::Duel);
        $factory->fixture($gameweek, $factory->squad('Ja B', [102], true), $factory->squad('On B', [202], false), MatchFormat::Duel);

        $this->em->flush();

        $crawler = $this->client->request('GET', '/kolejka/1');

        self::assertResponseIsSuccessful();
        self::assertCount(2, $crawler->filter('.match'));
    }

    public function testFourAgainstFourComesFirst(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);

        $factory->fixture($gameweek, $factory->squad('Ja A', [101], true), $factory->squad('On A', [201], false), MatchFormat::Duel);
        $factory->fixture($gameweek, $factory->squad('Kuba FC', [102, 103, 104, 105], true), $factory->squad('Rywale', [202, 203, 204, 205], false));

        $this->em->flush();

        $crawler = $this->client->request('GET', '/kolejka/1');

        self::assertStringContainsString('4x4', $crawler->filter('.match')->first()->text());
    }
```

Dopisz import `use App\League\MatchFormat;`.

W `tests/Functional/FixtureCurrentControllerTest.php` zamień oczekiwane przekierowanie z `'/fixture/7'` na `'/kolejka/7'`, a test o braku bieżącej kolejki zostaw bez zmian.

- [ ] **Step 2: Uruchom testy, potwierdź że padają**

Run: `vendor/bin/phpunit --filter "GameweekControllerTest|FixtureCurrentControllerTest"`
Expected: FAIL — 404 na `/kolejka/1`

- [ ] **Step 3: Dopisz metody repozytorium**

W `src/Repository/LeagueFixtureRepository.php` zamień `findOneByGameweek` i `findAllOrdered` na:

```php
    /**
     * Mecze kolejki w kolejnosci prezentacji: najpierw 4x4, bo to jedyna liga z
     * ciagloscia przez sezon, potem mecze dorazne w kolejnosci dodania.
     *
     * @return list<LeagueFixture>
     */
    public function findByGameweek(int $gameweek): array
    {
        /** @var list<LeagueFixture> $matches */
        $matches = $this->createQueryBuilder('f')
            ->andWhere('f.gameweek = :gameweek')
            ->setParameter('gameweek', $gameweek)
            ->orderBy('f.id', 'ASC')
            ->getQuery()
            ->getResult();

        // Kolejnosc prezentacji ustawiamy w PHP, a nie w DQL: wyrazenie CASE w
        // sortowaniu wymaga aliasu HIDDEN i psuje sie przy kazdej zmianie zapytania,
        // a meczow w kolejce sa jednostki.
        usort($matches, static function (LeagueFixture $a, LeagueFixture $b): int {
            $weight = static fn (LeagueFixture $f): int => MatchFormat::Squad4 === $f->getFormat() ? 0 : 1;

            return [$weight($a), $a->getId()] <=> [$weight($b), $b->getId()];
        });

        return $matches;
    }

    public function findFourByGameweek(int $gameweek): ?LeagueFixture
    {
        return $this->createQueryBuilder('f')
            ->andWhere('f.gameweek = :gameweek')
            ->andWhere('f.format = :format')
            ->setParameter('gameweek', $gameweek)
            ->setParameter('format', MatchFormat::Squad4->value)
            ->getQuery()
            ->getOneOrNullResult();
    }

    /**
     * Bilans sezonu liczy wylacznie 4x4 — mecze dorazne nie tworza ciagu, wiec
     * nie ma czego bilansowac.
     *
     * @return list<LeagueFixture>
     */
    public function findFourOrdered(): array
    {
        return $this->createQueryBuilder('f')
            ->andWhere('f.format = :format')
            ->setParameter('format', MatchFormat::Squad4->value)
            ->orderBy('f.gameweek', 'ASC')
            ->getQuery()
            ->getResult();
    }
```

Dopisz import `use App\League\MatchFormat;`.

- [ ] **Step 4: Napisz kontroler kolejki**

Utwórz `src/Controller/GameweekController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Fpl\Api\FplApiException;
use App\Fpl\Sync\DictionarySync;
use App\League\MatchViewBuilder;
use App\Repository\GameweekRepository;
use App\Repository\LeagueFixtureRepository;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class GameweekController extends AbstractController
{
    /**
     * Staly adres biezacej kolejki. Numer kolejki zmienia sie co tydzien, wiec
     * zewnetrzny cron odswiezajacy wyniki musi miec cel, ktory sam nadaza za
     * sezonem — inaczej do konca rozgrywek odswiezalby pierwsza kolejke.
     */
    #[Route('/teraz', name: 'fixture_current', methods: ['GET'])]
    public function current(GameweekRepository $gameweeks): Response
    {
        $gameweek = $gameweeks->findCurrent()
            ?? throw $this->createNotFoundException('Brak biezacej kolejki.');

        return $this->redirectToRoute('gameweek_show', ['gameweek' => $gameweek->getId()]);
    }

    #[Route('/kolejka/{gameweek}', name: 'gameweek_show', requirements: ['gameweek' => '\d+'], methods: ['GET'])]
    public function show(
        int $gameweek,
        Request $request,
        LeagueFixtureRepository $fixtures,
        MatchViewBuilder $builder,
        DictionarySync $dictionaries,
        ClockInterface $clock,
        LoggerInterface $logger,
    ): Response {
        // Bez tego snapshoty nigdy sie nie zamrazaja: znacznik "punkty ostateczne"
        // przychodzi ze slownikow, a te odswiezal dotad wylacznie formularz nowego
        // meczu i komenda konsolowa. Okno swiezosci to doba, wiec to najwyzej jedno
        // zapytanie dziennie.
        try {
            $dictionaries->sync();
        } catch (FplApiException $exception) {
            $logger->warning('Nie udalo sie odswiezyc slownikow przy widoku kolejki.', ['exception' => $exception]);
        }

        $matches = $fixtures->findByGameweek($gameweek);

        // Przycisk odswiezania omija piecominutowy odstep miedzy zapytaniami do
        // FPL. Po pobraniu wracamy na czysty adres, zeby odswiezenie strony w
        // przegladarce nie wymuszalo kolejnego zapytania przy kazdym F5.
        if ($request->query->getBoolean('odswiez')) {
            foreach ($matches as $fixture) {
                $builder->build($fixture, refresh: true, force: true);
            }

            return $this->redirectToRoute('gameweek_show', ['gameweek' => $gameweek]);
        }

        $views = [];
        $oldest = null;
        foreach ($matches as $fixture) {
            $view = $builder->build($fixture);
            $views[] = $view;

            if (null !== $view->oldestSyncedAt && (null === $oldest || $view->oldestSyncedAt < $oldest)) {
                $oldest = $view->oldestSyncedAt;
            }
        }

        $syncedMinutesAgo = null;
        if (null !== $oldest) {
            $syncedMinutesAgo = intdiv($clock->now()->getTimestamp() - $oldest->getTimestamp(), 60);
        }

        return $this->render('gameweek/show.html.twig', [
            'gameweek' => $gameweek,
            'views' => $views,
            'syncedMinutesAgo' => $syncedMinutesAgo,
        ]);
    }
}
```

- [ ] **Step 5: Usuń stare trasy z FixtureController**

W `src/Controller/FixtureController.php`:
- usuń metody `current()` i `show()` wraz z ich atrybutami tras,
- w `new()` zamień przekierowanie po udanym zapisie na `return $this->redirectToRoute('gameweek_show', ['gameweek' => $gameweek->getId()]);`,
- usuń importy, które przestały być używane (`MatchViewBuilder`, `ClockInterface`, jeśli nic ich już nie używa) — `php -l` nie wykryje nadmiarowych importów, więc sprawdź je wzrokiem.

- [ ] **Step 6: Wydziel blok meczu z szablonu**

Utwórz `templates/fixture/_match.html.twig`, przenosząc do niego panel wyniku i włączenia z dzisiejszego `templates/fixture/show.html.twig`:

```twig
{% set result = view.result %}
{% set format = fixture.format %}

<section class="match">
    <h2 class="match__title">{{ format.label }}</h2>

    <div class="panel">
        <p class="scoreline">
            <span class="mine">{{ fixture.mySquad.name }} {{ result.myPoints|number_format(1, ',', ' ') }}</span>
            <span>:</span>
            <span class="opponent">{{ result.opponentPoints|number_format(1, ',', ' ') }} {{ fixture.opponentSquad.name }}</span>
        </p>
        {% set total = result.myPoints + result.opponentPoints %}
        <div class="spine" aria-hidden="true">
            <div class="spine__mine" style="flex: {{ total > 0 ? result.myPoints : 1 }}"></div>
            <div class="spine__opponent" style="flex: {{ total > 0 ? result.opponentPoints : 1 }}"></div>
        </div>

        <p class="hint">
            {{ result.outcome.label }}
            {% if result.margin != 0 %}({{ result.margin > 0 ? '+' : '' }}{{ result.margin|number_format(1, ',', ' ') }}){% endif %}
            &middot; {{ format.scoreLabel }}
            &middot;
            {% if fixture.gameweek.dataChecked %}
                <span class="badge">wynik ostateczny</span>
            {% elseif fixture.gameweek.finished %}
                <span class="badge">kolejka zakonczona, trwa naliczanie bonusow</span>
            {% else %}
                <span class="badge">kolejka w toku</span>
            {% endif %}
        </p>

        {% if view.syncFailed %}
            <p class="stale">FPL API nie odpowiedzialo &mdash; pokazuje ostatnie zapisane dane.</p>
        {% endif %}
        {% if not view.hasAnyData %}
            <p class="stale">Brak danych z FPL &mdash; kolejka jeszcze sie nie rozpoczela.</p>
        {% endif %}
    </div>

    {% if format.usesDuels %}
        {% include 'fixture/_duels.html.twig' with {'result': result} only %}
    {% endif %}

    {% include 'fixture/_standings.html.twig' with {'view': view, 'fixture': fixture, 'format': format} only %}
    {% include 'fixture/_differentials.html.twig' with {'report': view.differentials} only %}
    {% include 'fixture/_picks.html.twig' with {'view': view, 'fixture': fixture} only %}
</section>
```

W `templates/fixture/_standings.html.twig` ukryj kolumnę punktów ligowych, gdy format ich nie zna: owiń nagłówek `PKT` i odpowiadającą mu komórkę w każdym wierszu w `{% if format.usesLeaguePoints %}` … `{% endif %}`. Nazwa zmiennej `format` przychodzi z włączenia powyżej.

Sprawdź, czy `_standings.html.twig` i `_picks.html.twig` nie sięgają po zmienne spoza przekazanych (`only` odcina kontekst nadrzędny). Jeśli sięgają, dopisz je do listy przekazywanych.

- [ ] **Step 7: Napisz stronę kolejki**

Utwórz `templates/gameweek/show.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% block title %}Kolejka {{ gameweek }}{% endblock %}

{% block body %}
    <h1>Kolejka {{ gameweek }}</h1>

    <p class="freshness">
        {% if syncedMinutesAgo is not null %}
            <span class="stale">Dane sprzed {{ syncedMinutesAgo }} min.</span>
        {% endif %}
        <a class="refresh" href="{{ path('gameweek_show', {'gameweek': gameweek, 'odswiez': 1}) }}">Odswiez</a>
    </p>

    {% if views is empty %}
        <p class="hint">Brak meczow w tej kolejce.</p>
    {% else %}
        {% for view in views %}
            {% include 'fixture/_match.html.twig' with {'view': view, 'fixture': view.fixture} only %}
        {% endfor %}
    {% endif %}
{% endblock %}
```

Usuń `templates/fixture/show.html.twig`.

- [ ] **Step 8: Popraw odnośniki i nazwę w pasku**

W `templates/base.html.twig`:
- zamień treść znacznika `<title>` domyślną z `Liga FPL 4x4` na `Liga FPL`,
- zamień tekst odnośnika `brand` z `Liga FPL 4x4` na `Liga FPL`,
- dopisz w nawigacji przed odnośnikiem `Bilans`: `<a href="{{ path('fixture_current') }}">Kolejka</a>`.

W `templates/dashboard/index.html.twig` zamień `path('fixture_show', {gameweek: view.fixture.gameweek.id})` na `path('gameweek_show', {gameweek: view.fixture.gameweek.id})`.

Sprawdź, czy nie zostały inne odwołania: `grep -rn "fixture_show" src/ templates/ tests/` — musi nic nie zwrócić.

- [ ] **Step 9: Dodaj styl nagłówka meczu**

W `public/css/app.css` dopisz na końcu:

```css
/* Kilka meczow na jednej stronie musi sie od siebie odcinac — odstep robi to
   lepiej niz kreska, zgodnie z reszta ukladu. */
.match { margin: 0 0 44px; }

.match__title {
    margin: 0 0 10px;
    color: var(--muted);
    font-size: 12px;
    font-weight: 800;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}
```

- [ ] **Step 10: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 11: Obejrzyj stronę**

Run: `php -S 127.0.0.1:8123 -t public` w tle, potem otwórz `http://127.0.0.1:8123/kolejka/1`.
Sprawdź: mecz 4x4 na górze, nagłówek z formatem nad każdym meczem, przycisk odświeżania nad wszystkimi meczami, brak poziomego przewijania.

- [ ] **Step 12: Commit**

```bash
git add src/ templates/ public/css/app.css tests/
git commit -m "feat: strona kolejki ze wszystkimi meczami"
```

---

### Task 8: Bilans sezonu wyłącznie z 4x4

**Files:**
- Modify: `src/Controller/DashboardController.php`
- Modify: `tests/Functional/DashboardControllerTest.php`

**Interfaces:**
- Consumes: `LeagueFixtureRepository::findFourOrdered()` (Task 7).
- Produces: brak nowych sygnatur.

- [ ] **Step 1: Napisz padający test**

Dopisz do `tests/Functional/DashboardControllerTest.php`:

```php
    public function testSeasonRecordIgnoresAdHocMatches(): void
    {
        // Mecze dorazne nie tworza ciagu przez sezon, wiec nie moga wchodzic do
        // bilansu — inaczej pojedynek przegrany w kolejce 1 psulby tabele 4x4.
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);

        $factory->fixture($gameweek, $factory->squad('Kuba FC', [101, 102, 103, 104], true), $factory->squad('Rywale', [201, 202, 203, 204], false));
        $factory->fixture($gameweek, $factory->squad('Ja sam', [105], true), $factory->squad('On sam', [205], false), MatchFormat::Duel);

        $this->em->flush();

        $crawler = $this->client->request('GET', '/');

        self::assertResponseIsSuccessful();
        self::assertCount(1, $crawler->filter('table.fixtures tbody tr'), 'W bilansie widac wylacznie mecze 4x4.');
    }
```

Dopisz importy `use App\League\MatchFormat;` i `use App\Tests\Support\EntityFactory;`, jeśli ich brakuje.

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter testSeasonRecordIgnoresAdHocMatches`
Expected: FAIL — dwa wiersze zamiast jednego

- [ ] **Step 3: Zawęź źródło danych bilansu**

W `src/Controller/DashboardController.php` zamień pętlę:

```php
        $views = [];
        foreach ($fixtures->findFourOrdered() as $fixture) {
            $views[] = $builder->build($fixture, false);
        }
```

- [ ] **Step 4: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add src/Controller/DashboardController.php tests/Functional/DashboardControllerTest.php
git commit -m "feat: bilans sezonu liczony wylacznie z meczow 4x4"
```

---

### Task 9: Formularz nowego meczu z formatem i obiema stronami

**Files:**
- Modify: `src/Form/FixtureFormModel.php`
- Modify: `src/Form/FixtureFormType.php`
- Modify: `src/Controller/FixtureController.php`
- Modify: `templates/fixture/new.html.twig`
- Modify: `tests/Functional/FixtureNewControllerTest.php`

**Interfaces:**
- Consumes: `MatchFormat` (Task 1), `SquadManager::createSideSquad()` (Task 1), `LeagueFixture::addDuel()` (Task 2), `LeagueFixtureRepository::findFourByGameweek()` (Task 7).
- Produces:
  - `FixtureFormModel` z polami: `?int $gameweek`, `?string $format`, `?string $myName`, `?string $my1..$my4`, `?string $opponentName`, `?string $opponent1..$opponent4`, `?int $pair1..$pair3`
  - `FixtureFormModel::sideInputs(string $prefix, int $size): list<string>` — zwraca surowe wartości pól `my1..myN` albo `opponent1..opponentN`
  - `FixtureFormType` z polem `format` typu `ChoiceType` (wartości: wartości enuma, etykiety: `MatchFormat::label()`)

Zmiana nazw pól rywala z `entry1..4` na `opponent1..4` jest konieczna, bo po stronie mojej dochodzi drugi komplet. Popraw wszystkie odwołania w kontrolerze, szablonie i testach.

Reguły sprawdzane w kontrolerze, w tej kolejności — pierwsza, która zawiedzie, kończy sprawdzanie:

1. kolejka istnieje w słownikach,
2. dla formatu 4x4: ta kolejka nie ma jeszcze meczu 4x4,
3. dla formatu 4x4: stała czwórka jest kompletna,
4. liczba drużyn po każdej stronie zgadza się z formatem (puste pola ponad rozmiar formatu są ignorowane, brakujące dają błąd przy polu),
5. drużyny w obrębie strony są różne,
6. żadna drużyna nie występuje po obu stronach,
7. każda wskazana drużyna daje się rozstrzygnąć w katalogu,
8. dla trójek: `pair1`, `pair2`, `pair3` są permutacją zbioru {1,2,3}.

- [ ] **Step 1: Napisz padające testy**

Najpierw zaktualizuj istniejące metody pomocnicze w `tests/Functional/FixtureNewControllerTest.php`. Domyślne pola w `submit()` zmieniają nazwy i zyskują format:

```php
    /** @param array<string, string> $overrides */
    private function submit(array $overrides = []): void
    {
        $crawler = $this->client->request('GET', '/fixture/new');

        $this->client->submit($crawler->selectButton('Dodaj mecz')->form(array_merge([
            'fixture[gameweek]' => '1',
            'fixture[format]' => 'four',
            'fixture[opponentName]' => 'Rywale',
            'fixture[opponent1]' => '201',
            'fixture[opponent2]' => '202',
            'fixture[opponent3]' => '203',
            'fixture[opponent4]' => '204',
        ], $overrides)));
    }
```

Popraw wszystkie istniejące testy w tym pliku: `fixture[entryN]` → `fixture[opponentN]`, oczekiwane przekierowania `/fixture/1` → `/kolejka/1`, a wywołania `findOneByGameweek(1)` → `findFourByGameweek(1)`.

Test `testRedirectsToSettingsWhenSquadMissing` przestaje opisywać prawdę: brak stałej czwórki blokuje odtąd wyłącznie format 4x4, a nie wejście na formularz. Zamień go na:

```php
    public function testFormOpensWithoutAConfiguredSquad(): void
    {
        // Bez stalej czworki nie da sie dodac meczu 4x4, ale mecz dorazny owszem —
        // formularz musi sie wiec otworzyc.
        $this->client->request('GET', '/fixture/new');

        self::assertResponseIsSuccessful();
    }

    public function testRejectsFourAgainstFourWithoutAConfiguredSquad(): void
    {
        $factory = new EntityFactory($this->em);
        $factory->gameweek(1);
        $this->em->flush();

        $this->submit();

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'skonfiguruj swoja czworke');
    }
```

Dopisz nowe testy:

```php
    public function testCreatesADuelMatch(): void
    {
        $this->configureMySquad();

        $this->submit([
            'fixture[gameweek]' => '2',
            'fixture[format]' => 'duel',
            'fixture[myName]' => 'Ja sam',
            'fixture[my1]' => '301',
            'fixture[opponentName]' => 'On sam',
            'fixture[opponent1]' => '401',
        ]);

        self::assertResponseRedirects('/kolejka/2');

        $fixture = self::getContainer()->get(LeagueFixtureRepository::class)->findByGameweek(2)[0];
        self::assertSame(MatchFormat::Duel, $fixture->getFormat());
        self::assertSame([301], $fixture->getMySquad()->entryIds());
        self::assertSame([401], $fixture->getOpponentSquad()->entryIds());
    }

    public function testCreatesATrioMatchWithPairings(): void
    {
        $this->configureMySquad();

        $this->submit([
            'fixture[gameweek]' => '2',
            'fixture[format]' => 'trio',
            'fixture[myName]' => 'Moja trojka',
            'fixture[my1]' => '301',
            'fixture[my2]' => '302',
            'fixture[my3]' => '303',
            'fixture[opponentName]' => 'Ich trojka',
            'fixture[opponent1]' => '401',
            'fixture[opponent2]' => '402',
            'fixture[opponent3]' => '403',
            'fixture[pair1]' => '2',
            'fixture[pair2]' => '3',
            'fixture[pair3]' => '1',
        ]);

        self::assertResponseRedirects('/kolejka/2');

        $fixture = self::getContainer()->get(LeagueFixtureRepository::class)->findByGameweek(2)[0];
        $duels = $fixture->getDuels();

        self::assertCount(3, $duels);
        self::assertSame(301, $duels[0]->getMyEntry()->getId());
        self::assertSame(402, $duels[0]->getOpponentEntry()->getId(), 'Moja 1 gra z ich 2.');
        self::assertSame(403, $duels[1]->getOpponentEntry()->getId());
        self::assertSame(401, $duels[2]->getOpponentEntry()->getId());
    }

    public function testRejectsTrioPairingsThatRepeatAnOpponent(): void
    {
        $this->configureMySquad();

        $this->submit([
            'fixture[gameweek]' => '2',
            'fixture[format]' => 'trio',
            'fixture[myName]' => 'Moja trojka',
            'fixture[my1]' => '301',
            'fixture[my2]' => '302',
            'fixture[my3]' => '303',
            'fixture[opponentName]' => 'Ich trojka',
            'fixture[opponent1]' => '401',
            'fixture[opponent2]' => '402',
            'fixture[opponent3]' => '403',
            'fixture[pair1]' => '1',
            'fixture[pair2]' => '1',
            'fixture[pair3]' => '3',
        ]);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'dokladnie jednym pojedynku');
        self::assertSame(0, $this->countFixturesForGameweek(2));
    }

    public function testAllowsSecondDuelInTheSameGameweek(): void
    {
        $this->configureMySquad();

        $duel = [
            'fixture[gameweek]' => '2',
            'fixture[format]' => 'duel',
            'fixture[myName]' => 'Ja A',
            'fixture[my1]' => '301',
            'fixture[opponentName]' => 'On A',
            'fixture[opponent1]' => '401',
        ];

        $this->submit($duel);
        $this->submit(array_merge($duel, [
            'fixture[myName]' => 'Ja B',
            'fixture[my1]' => '302',
            'fixture[opponentName]' => 'On B',
            'fixture[opponent1]' => '402',
        ]));

        self::assertSame(2, $this->countFixturesForGameweek(2));
    }

    public function testRejectsSecondFourAgainstFourInTheSameGameweek(): void
    {
        $this->configureMySquad();

        $this->submit(['fixture[gameweek]' => '2']);
        $this->submit(['fixture[gameweek]' => '2', 'fixture[opponent1]' => '301']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'Mecz 4x4 na ta kolejke juz istnieje');
        self::assertSame(1, $this->countFixturesForGameweek(2));
    }

    public function testRejectsEntryAppearingOnBothSides(): void
    {
        $this->configureMySquad();

        $this->submit([
            'fixture[gameweek]' => '2',
            'fixture[format]' => 'duo',
            'fixture[myName]' => 'Moi',
            'fixture[my1]' => '301',
            'fixture[my2]' => '302',
            'fixture[opponentName]' => 'Oni',
            'fixture[opponent1]' => '302',
            'fixture[opponent2]' => '301',
        ]);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'po obu stronach');
        self::assertSame(0, $this->countFixturesForGameweek(2));
    }
```

Numery 301-303 i 401-403 nie występują w stałej czwórce (101-104) ani wśród domyślnych rywali (201-204), więc żaden test nie potyka się o regułę „drużyna po obu stronach". Drużyn spoza bazy `SquadManager` dobiera z atrapy FPL API skonfigurowanej w środowisku testowym — tak samo, jak dzieje się to dziś dla numerów 201-204.

- [ ] **Step 2: Uruchom testy, potwierdź że padają**

Run: `vendor/bin/phpunit --filter FixtureNewControllerTest`
Expected: FAIL — nieznane pola formularza

- [ ] **Step 3: Rozszerz model formularza**

Zamień zawartość `src/Form/FixtureFormModel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Validator\Constraints as Assert;

/**
 * Pola my1-4 i opponent1-4 przyjmuja albo numer ID druzyny, albo jej nazwe do
 * wyszukania w katalogu (App\Repository\FplEntryRepository). Rozstrzygniecie
 * ktore to jest i ewentualne wyszukanie wymaga dostepu do bazy, wiec dzieje sie
 * w FixtureController, nie tutaj.
 *
 * Ile pol jest wymaganych, zalezy od formatu, a formatu nie zna walidator
 * atrybutow — dlatego tutaj sprawdzamy wylacznie ksztalt wartosci, a
 * kompletnosc stron sprawdza kontroler.
 */
class FixtureFormModel
{
    #[Assert\NotNull(message: 'Podaj numer kolejki.')]
    #[Assert\Range(min: 1, max: 38, notInRangeMessage: 'Kolejka musi byc z zakresu {{ min }}-{{ max }}.')]
    public ?int $gameweek = null;

    #[Assert\NotBlank(message: 'Wybierz format meczu.')]
    public ?string $format = null;

    #[Assert\Length(max: 100)]
    public ?string $myName = null;

    #[Assert\Length(max: 100)]
    public ?string $my1 = null;

    #[Assert\Length(max: 100)]
    public ?string $my2 = null;

    #[Assert\Length(max: 100)]
    public ?string $my3 = null;

    #[Assert\Length(max: 100)]
    public ?string $my4 = null;

    #[Assert\NotBlank(message: 'Podaj nazwe druzyny rywala.')]
    #[Assert\Length(max: 100)]
    public ?string $opponentName = null;

    #[Assert\Length(max: 100)]
    public ?string $opponent1 = null;

    #[Assert\Length(max: 100)]
    public ?string $opponent2 = null;

    #[Assert\Length(max: 100)]
    public ?string $opponent3 = null;

    #[Assert\Length(max: 100)]
    public ?string $opponent4 = null;

    #[Assert\Range(min: 1, max: 3)]
    public ?int $pair1 = null;

    #[Assert\Range(min: 1, max: 3)]
    public ?int $pair2 = null;

    #[Assert\Range(min: 1, max: 3)]
    public ?int $pair3 = null;

    /**
     * @param 'my'|'opponent' $prefix
     *
     * @return list<string>
     */
    public function sideInputs(string $prefix, int $size): array
    {
        $values = [];
        for ($slot = 1; $slot <= $size; ++$slot) {
            $values[] = trim((string) $this->{$prefix.$slot});
        }

        return $values;
    }

    /** @return list<int> */
    public function pairs(): array
    {
        return [(int) $this->pair1, (int) $this->pair2, (int) $this->pair3];
    }
}
```

- [ ] **Step 4: Rozszerz typ formularza**

Zamień `buildForm` w `src/Form/FixtureFormType.php`:

```php
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $formats = [];
        foreach (MatchFormat::cases() as $format) {
            $formats[$format->label()] = $format->value;
        }

        $builder
            ->add('gameweek', IntegerType::class, ['label' => 'Kolejka', 'required' => false])
            ->add('format', ChoiceType::class, [
                'label' => 'Format',
                'required' => false,
                'choices' => $formats,
                'placeholder' => 'wybierz format',
            ])
            ->add('myName', TextType::class, ['label' => 'Nazwa mojej strony', 'required' => false])
            ->add('opponentName', TextType::class, ['label' => 'Nazwa druzyny rywala', 'required' => false]);

        foreach ([1, 2, 3, 4] as $slot) {
            $builder->add('my'.$slot, TextType::class, [
                'label' => 'Moja druzyna '.$slot,
                'required' => false,
                'attr' => ['list' => 'fpl-entry-suggestions'],
            ]);
            $builder->add('opponent'.$slot, TextType::class, [
                'label' => 'Numer albo nazwa druzyny rywala '.$slot,
                'required' => false,
                'attr' => ['list' => 'fpl-entry-suggestions'],
            ]);
        }

        foreach ([1, 2, 3] as $slot) {
            $builder->add('pair'.$slot, ChoiceType::class, [
                'label' => 'Moja '.$slot.' gra z ich',
                'required' => false,
                'choices' => ['1' => 1, '2' => 2, '3' => 3],
                'placeholder' => '—',
            ]);
        }
    }
```

Dopisz importy `use App\League\MatchFormat;` i `use Symfony\Component\Form\Extension\Core\Type\ChoiceType;`.

- [ ] **Step 5: Przepisz kontroler dodawania meczu**

W `src/Controller/FixtureController.php` zamień metodę `new()` i pomocnicze na:

```php
    #[Route('/fixture/new', name: 'fixture_new', methods: ['GET', 'POST'])]
    public function new(
        Request $request,
        SquadRepository $squads,
        SquadManager $squadManager,
        GameweekRepository $gameweeks,
        LeagueFixtureRepository $fixtures,
        FplEntryRepository $entries,
        DictionarySync $dictionaries,
        EntityManagerInterface $em,
        ClockInterface $clock,
        LoggerInterface $logger,
    ): Response {
        $mySquad = $squads->findMine();

        // Jesli zapis meczu zawiedzie w transakcji w tryCreate(), Doctrine zamyka
        // menedzera encji (wrapInTransaction robi to przy kazdym wyjatku). Wczytujemy
        // dane potrzebne do renderowania szablonu juz teraz, zeby po takim zdarzeniu
        // nie trzeba bylo niczego doladowywac przez zamknietego EM-a.
        if (null !== $mySquad) {
            foreach ($mySquad->entries() as $entry) {
                $entry->getTeamName();
            }
        }

        try {
            $dictionaries->sync();
        } catch (FplApiException $exception) {
            $logger->warning('Nie udalo sie odswiezyc slownikow.', ['exception' => $exception]);
        }

        $model = new FixtureFormModel();
        $form = $this->createForm(FixtureFormType::class, $model);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $format = MatchFormat::tryFrom((string) $model->format);
            $gameweek = $gameweeks->find((int) $model->gameweek);

            if (null === $format) {
                $form->get('format')->addError(new FormError('Nieznany format meczu.'));
            } elseif (null === $gameweek) {
                $form->addError(new FormError(sprintf('Nie znam kolejki numer %d. Odswiez slowniki i sprobuj ponownie.', (int) $model->gameweek)));
            } elseif (MatchFormat::Squad4 === $format && null !== $fixtures->findFourByGameweek($gameweek->getId())) {
                $form->addError(new FormError('Mecz 4x4 na ta kolejke juz istnieje.'));
            } elseif (MatchFormat::Squad4 === $format && (null === $mySquad || !$mySquad->isComplete($format->squadSize()))) {
                $form->addError(new FormError('Najpierw skonfiguruj swoja czworke.'));
            } elseif (null === $sides = $this->resolveSides($form, $model, $format, $entries, $mySquad)) {
                // bledy dopisane do poszczegolnych pol w resolveSides()
            } elseif (null === $duelSlots = $this->resolveDuelSlots($form, $model, $format)) {
                // blad dopisany w resolveDuelSlots()
            } elseif (null === $this->tryCreate($form, $squadManager, $em, $clock, $model, $format, $sides, $duelSlots, $gameweek)) {
                // blad zostal dopisany do formularza w tryCreate()
            } else {
                $this->addFlash('success', 'Dodano mecz kolejki '.$gameweek->getId().'.');

                return $this->redirectToRoute('gameweek_show', ['gameweek' => $gameweek->getId()]);
            }
        }

        return $this->render('fixture/new.html.twig', [
            'form' => $form->createView(),
            'mySquad' => $mySquad,
            'entrySuggestions' => $entries->findRecentlyAdded(self::ENTRY_SUGGESTIONS_LIMIT),
        ]);
    }

    /**
     * Rozstrzyga obie strony meczu. W 4x4 moja strona bierze sie ze stalej czworki
     * i nie jest wpisywana w formularzu; w pozostalych formatach obie strony
     * wskazuje sie tak samo.
     *
     * @return array{mine: list<int>, opponent: list<int>}|null
     */
    private function resolveSides(
        FormInterface $form,
        FixtureFormModel $model,
        MatchFormat $format,
        FplEntryRepository $entries,
        ?Squad $mySquad,
    ): ?array {
        $size = $format->squadSize();

        if (MatchFormat::Squad4 === $format) {
            $mine = $mySquad?->entryIds() ?? [];
        } else {
            $mine = $this->resolveSide($form, $model->sideInputs('my', $size), 'my', $entries);
        }

        $opponent = $this->resolveSide($form, $model->sideInputs('opponent', $size), 'opponent', $entries);

        if (null === $mine || null === $opponent) {
            return null;
        }

        foreach (['my' => $mine, 'opponent' => $opponent] as $prefix => $ids) {
            if (count($ids) !== count(array_unique($ids))) {
                $form->get($prefix.'1')->addError(new FormError('Druzyny po jednej stronie musza byc rozne.'));

                return null;
            }
        }

        $common = array_intersect($mine, $opponent);
        if ([] !== $common) {
            $form->addError(new FormError(sprintf('Druzyna %d wystepuje po obu stronach.', (int) reset($common))));

            return null;
        }

        return ['mine' => $mine, 'opponent' => $opponent];
    }

    /**
     * Rozstrzyga pola jednej strony: sama liczba to ID wprost, tekst szuka sie w
     * katalogu (FplEntryRepository, nie FPL API — to lokalna baza, wiec dzieje sie
     * przed jakimkolwiek odpytaniem API). Brak trafien, wiele trafien albo puste
     * pole dopisuje blad przy tym konkretnym polu.
     *
     * @param list<string> $inputs
     * @param 'my'|'opponent' $prefix
     *
     * @return list<int>|null
     */
    private function resolveSide(FormInterface $form, array $inputs, string $prefix, FplEntryRepository $entries): ?array
    {
        $resolved = [];
        $allResolved = true;

        foreach ($inputs as $index => $raw) {
            $field = $prefix.($index + 1);

            if ('' === $raw) {
                $form->get($field)->addError(new FormError('Podaj numer albo nazwe druzyny.'));
                $allResolved = false;

                continue;
            }

            if (1 === preg_match('/^\d+$/', $raw)) {
                $resolved[] = (int) $raw;

                continue;
            }

            $matches = $entries->searchByNameFragment($raw);

            if ([] === $matches) {
                $form->get($field)->addError(new FormError(sprintf('Nie znaleziono druzyny "%s". Mozesz podac numer ID.', $raw)));
                $allResolved = false;
            } elseif (1 === count($matches)) {
                $resolved[] = $matches[0]->getId();
            } else {
                $form->get($field)->addError(new FormError(sprintf(
                    'Znaleziono kilka druzyn pasujacych do "%s": %s. Podaj numer ID wybranej.',
                    $raw,
                    implode(', ', array_map(
                        static fn (FplEntry $entry): string => sprintf('%s (%d)', $entry->getTeamName(), $entry->getId()),
                        $matches,
                    )),
                )));
                $allResolved = false;
            }
        }

        return $allResolved ? $resolved : null;
    }

    /**
     * Numery pojedynkow: pozycja i-ta mowi, z ktora druzyna rywala gra moja i-ta.
     * Poza trojkami pojedynkow nie ma, wiec lista jest pusta.
     *
     * @return list<int>|null
     */
    private function resolveDuelSlots(FormInterface $form, FixtureFormModel $model, MatchFormat $format): ?array
    {
        if (!$format->usesDuels()) {
            return [];
        }

        $slots = $model->pairs();

        $sorted = $slots;
        sort($sorted);

        // Kazdy z trzech numerow ma paść dokladnie raz — to jednoczesnie odrzuca
        // powtorzenia, braki i wartosci spoza zakresu.
        if ([1, 2, 3] !== $sorted) {
            $form->get('pair1')->addError(new FormError('Kazda druzyna rywala musi wystapic w dokladnie jednym pojedynku.'));

            return null;
        }

        return $slots;
    }

    /**
     * @param array{mine: list<int>, opponent: list<int>} $sides
     * @param list<int>                                   $duelSlots
     */
    private function tryCreate(
        FormInterface $form,
        SquadManager $squadManager,
        EntityManagerInterface $em,
        ClockInterface $clock,
        FixtureFormModel $model,
        MatchFormat $format,
        array $sides,
        array $duelSlots,
        Gameweek $gameweek,
    ): ?LeagueFixture {
        try {
            // Skladny obu stron i mecz musza powstac razem: jesli zapis meczu
            // zawiedzie, transakcja wycofuje takze utworzone przed chwila skladny,
            // zeby nie zostaly osierocone w bazie.
            return $em->wrapInTransaction(function () use ($squadManager, $em, $clock, $model, $format, $sides, $duelSlots, $gameweek): LeagueFixture {
                $size = $format->squadSize();

                $myName = '' !== trim((string) $model->myName) ? trim((string) $model->myName) : 'Moja strona';
                $mySquad = $squadManager->createSideSquad($myName, true, $sides['mine'], $size);
                $opponentSquad = $squadManager->createSideSquad((string) $model->opponentName, false, $sides['opponent'], $size);

                $fixture = new LeagueFixture($gameweek, $mySquad, $opponentSquad, $format, $clock->now());

                if ($format->usesDuels()) {
                    $myEntries = $mySquad->entries();
                    $theirEntries = $opponentSquad->entries();

                    foreach ($duelSlots as $index => $opponentSlot) {
                        $fixture->addDuel($index + 1, $myEntries[$index], $theirEntries[$opponentSlot - 1]);
                    }
                }

                $em->persist($fixture);

                return $fixture;
            });
        } catch (UnknownEntryException|\InvalidArgumentException $exception) {
            $form->addError(new FormError($exception->getMessage()));

            return null;
        } catch (UniqueConstraintViolationException) {
            $form->addError(new FormError('Mecz na ta kolejke juz istnieje.'));

            return null;
        } catch (FplApiException) {
            $form->addError(new FormError('FPL API nie odpowiada, sprobuj za chwile.'));

            return null;
        }
    }
```

Usuń metody `resolveOpponentIds`, `hasDuplicates` i `findOverlap` — zastępuje je `resolveSides`. Dopisz importy `use App\Entity\Gameweek;`, `use App\Entity\Squad;`, `use App\League\MatchFormat;`.

- [ ] **Step 6: Zaktualizuj szablon formularza**

W `templates/fixture/new.html.twig`:
- dodaj wiersz z polem `format` zaraz po `gameweek`,
- pod nim sekcję „Moja strona" z polami `myName` i `my1`–`my4`,
- zamień dotychczasowe pola `entry1`–`entry4` na `opponent1`–`opponent4`,
- dodaj sekcję „Pojedynki" z polami `pair1`–`pair3`.

Bez JavaScriptu wszystkie pola są zawsze widoczne. Opisz to nad formularzem jednym zdaniem, żeby układ nie mylił:

```twig
<p class="hint">
    Wypelnij tyle pol, ile wymaga wybrany format: jedno w pojedynku, dwa w duetach,
    trzy w trojkach, cztery w 4x4. W 4x4 moja strona bierze sie ze stalej czworki,
    wiec pol „Moja druzyna" nie wypelniasz. Pojedynki dotycza wylacznie trojek.
</p>
```

- [ ] **Step 7: Uruchom testy formularza**

Run: `vendor/bin/phpunit --filter FixtureNewControllerTest`
Expected: PASS

- [ ] **Step 8: Uruchom całą suitę**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 9: Commit**

```bash
git add src/ templates/ tests/
git commit -m "feat: formularz meczu z formatem, obiema stronami i parami pojedynkow"
```

---

### Task 10: Widok pojedynków w trójkach

**Files:**
- Create: `templates/fixture/_duels.html.twig`
- Modify: `public/css/app.css`
- Modify: `tests/Functional/GameweekControllerTest.php`

**Interfaces:**
- Consumes: `MatchResult::$duels` (Task 3 i 5), włączenie z `templates/fixture/_match.html.twig` (Task 7) przekazujące zmienną `result`.
- Produces: brak nowych sygnatur.

- [ ] **Step 1: Napisz padający test**

Dopisz do `tests/Functional/GameweekControllerTest.php`:

```php
    public function testTrioMatchListsItsThreeDuels(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);
        $mine = $factory->squad('Moja trojka', [101, 102, 103], true);
        $theirs = $factory->squad('Ich trojka', [201, 202, 203], false);
        $fixture = $factory->fixture($gameweek, $mine, $theirs, MatchFormat::Trio);

        $myEntries = $mine->entries();
        $theirEntries = $theirs->entries();
        $fixture->addDuel(1, $myEntries[0], $theirEntries[1]);
        $fixture->addDuel(2, $myEntries[1], $theirEntries[2]);
        $fixture->addDuel(3, $myEntries[2], $theirEntries[0]);

        $this->em->flush();

        $crawler = $this->client->request('GET', '/kolejka/1');

        self::assertResponseIsSuccessful();
        self::assertCount(3, $crawler->filter('.duels tbody tr'));
    }
```

- [ ] **Step 2: Uruchom test, potwierdź że pada**

Run: `vendor/bin/phpunit --filter testTrioMatchListsItsThreeDuels`
Expected: FAIL — zero wierszy, bo szablonu jeszcze nie ma

- [ ] **Step 3: Napisz szablon pojedynków**

Utwórz `templates/fixture/_duels.html.twig`:

```twig
<table class="duels">
    <thead>
    <tr>
        <th class="num">#</th>
        <th>Moja</th>
        <th class="num">pkt</th>
        <th class="num">pkt</th>
        <th>Ich</th>
    </tr>
    </thead>
    <tbody>
    {% for duel in result.duels %}
        <tr class="duel duel--{{ duel.outcome.value }}">
            <td class="num">{{ duel.slot }}</td>
            <td class="mine">{{ duel.mine.label }}</td>
            <td class="num">{{ duel.mine.netPoints }}</td>
            <td class="num">{{ duel.opponent.netPoints }}</td>
            <td class="opponent">{{ duel.opponent.label }}</td>
        </tr>
    {% endfor %}
    </tbody>
</table>
```

- [ ] **Step 4: Dodaj styl listy pojedynków**

W `public/css/app.css` dopisz na końcu:

```css
/* Pojedynki w trojkach: wynik meczu bierze sie z ich sumy, wiec strona wygrana
   musi byc czytelna jednym spojrzeniem. Kolor bierze wygrany, przegrany blednie. */
.duels { width: 100%; border-collapse: collapse; margin: 0 0 18px; }
.duels th { color: var(--faint); font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; text-align: left; padding: 0 8px 6px; }
.duels td { padding: 7px 8px; background: var(--panel); }
.duels tr td:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); }
.duels tr td:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; }
.duel--win .mine { color: var(--mine); font-weight: 800; }
.duel--loss .opponent { color: var(--theirs); font-weight: 800; }
.duel--draw td { color: var(--muted); }
```

- [ ] **Step 5: Uruchom testy**

Run: `vendor/bin/phpunit`
Expected: PASS

- [ ] **Step 6: Obejrzyj stronę**

Otwórz `http://127.0.0.1:8123/kolejka/1` z dodanym meczem trójek.
Sprawdź: trzy wiersze pojedynków nad tabelą drużyn, wygrana strona wyróżniona kolorem, remis wyszarzony, brak poziomego przewijania na wąskim oknie.

- [ ] **Step 7: Commit**

```bash
git add templates/fixture/_duels.html.twig public/css/app.css tests/Functional/GameweekControllerTest.php
git commit -m "feat: lista pojedynkow w meczach trojek"
```

---

## Weryfikacja końcowa

- [ ] `vendor/bin/phpunit` — cała suita zielona
- [ ] `php bin/console doctrine:schema:validate` — mapowanie zgodne ze schematem
- [ ] `grep -rn "fixture_show\|createOpponentSquad\|Squad::SIZE" src/ templates/ tests/` — brak trafień
- [ ] `/kolejka/1` pokazuje mecz 4x4 pierwszej kolejki z tym samym wynikiem, co przed zmianą
- [ ] `/teraz` przekierowuje na stronę bieżącej kolejki
- [ ] `/` pokazuje bilans liczony wyłącznie z 4x4
- [ ] Dodanie meczu w każdym z czterech formatów kończy się przekierowaniem na stronę kolejki
