# Raport: różnice w składach parami (trójki) + waga zamiast liczby drużyn

Gałąź: `roznice-parami` (nie scalona do `main`). Zadanie wyjściowe: w formacie
trójki sekcja „Różnice w składach" ma pokazywać trzy osobne bloki (po jednym
na pojedynek), zamiast dwóch zbiorczych tabel całej trójki na całą trójkę —
zarówno w rozegranym/trwającym meczu, jak i w podglądzie przed startem
kolejki. W trakcie pracy właściciel dołożył drugie zlecenie na tej samej
gałęzi: kolumna dziś licząca *liczbę drużyn* wystawiających zawodnika ma
liczyć *sumę mnożników* (wagę) — bo wpis „0–1" przy podwojonym bilansie
punktowym (opaska kapitańska) wyglądał jak błąd.

## Zmiany plik po pliku

### `src/League/PlayerDifferential.php`
Pola `mineStartedBy`/`opponentStartedBy` przemianowane na `mineWeight`/
`opponentWeight` i zmienione znaczenie: to już nie liczba drużyn, tylko suma
mnożników wystawień w jedenastce (zwykłe wystawienie = 1, opaska = 2,
Potrójny Kapitan = 3; ławka = 0 i nie wchodzi do sumy). `edge()` liczy się
teraz jako różnica wag, nie różnica liczby drużyn — to jedyna zmiana logiki
w tej klasie, `net()` bez zmian.

### `src/League/DifferentialAnalyzer.php`
`accumulate()`: zamiast `++$totals[...][$startedKey]` sumuje
`$pick->getMultiplier()`. Reszta pliku (grupowanie po zawodniku, podział na
`mineEdge`/`opponentEdge`, sortowanie) nietknięta — to nadal jedyne miejsce,
które liczy różnice; budowa różnic parami (niżej) woła tę samą metodę
`analyze()` z jednoelementowymi listami zamiast pisać drugą implementację.

### `src/League/DifferentialReport.php`
Tylko doc-komentarze zaktualizowane na słownictwo „waga" zamiast „liczba
drużyn" — `mineEdgeCount()`/`opponentEdgeCount()` liczą dokładnie to samo co
wcześniej (sumę `edge()`), zmieniło się tylko znaczenie samego `edge()`.

### `src/League/MatchView.php`
Nowe pole `array $duelDifferentials = []` (klucz = numer pojedynku,
`App\Entity\Duel::getSlot()`) i metoda `duelDifferentialFor(int $slot):
?DifferentialReport`. Pole `differentials` (zbiorcze) zostaje bez zmian —
liczone tak samo jak dotąd, dla wszystkich formatów, także trójek (przydaje
się jako fallback, gdy mecz nie ma zapisanych par).

### `src/League/MatchViewBuilder.php`
Nowa prywatna metoda `duelDifferentials(list<Duel> $duels, array $snapshots):
array` — dla każdego `Duel` woła `DifferentialAnalyzer::analyze()` z listą
zawierającą wyłącznie snapshot mojej drużyny z pojedynku i wyłącznie snapshot
drużyny rywala z tego samego pojedynku (przez istniejący prywatny helper
`snapshotsOf()`). Wywoływana tylko gdy `$fixture->getFormat()->usesDuels()`;
wynik trafia do nowego pola `MatchView::$duelDifferentials`.

### `src/League/UpcomingMatchPreview.php`
Analogicznie: nowe pole `duelDifferentials` (prywatne, jak `$snapshots` i
`$chips`) i metoda `duelDifferentialFor()`.

### `src/League/UpcomingMatchPreviewBuilder.php`
Analogiczna metoda `duelDifferentials()`, ale liczona ze
**znormalizowanych** snapshotów (`$normalizedSnapshots`, po
`ChipNeutralSnapshot::from()`) — dokładnie tych samych, z których liczy się
już istniejące pole zbiorcze `$differentials`. Dzięki temu drużyna po Bench
Booście nie wygląda w żadnym pojedynku na sztucznie mocniejszą, a Potrójny
Kapitan normalizuje się do zwykłej opaski (waga 2, nie 3) tak samo jak w
podglądzie składu.

### `templates/fixture/_differentials.html.twig`
Przyjmuje teraz `report`, `mineLabel`, `opponentLabel`, `title`, `live` —
zamiast sięgać po `fixture.mySquad.name`/`fixture.opponentSquad.name` i
`fixture.gameweek.finished` samodzielnie. Dzięki temu ten sam plik obsługuje
i tabelę zbiorczą (nazwy stron), i pojedynczy pojedynek (nazwy konkretnych
drużyn) — wywołujący decyduje, co i jak nazwać. Kolumna wystawień zmieniona z
`{{ row.mineStartedBy }}–{{ row.opponentStartedBy }}` na `{{ row.mineWeight
}}x–{{ row.opponentWeight }}x`, tekst wyjaśniający nad tabelą przepisany na
opis wag („zwykłe wystawienie waży 1, opaska kapitańska 2, Potrójny Kapitan
3"). Układ `<div class="col">` wokół każdej karty `.bucket` zostawiony bez
zmian — to on utrzymuje odstępy w siatce Bootstrapa.

### `templates/fixture/_match.html.twig`
W sekcji różnic: jeśli `format.usesDuels` i `fixture.duels|length > 0`, pętla
po `fixture.duels` renderuje partiał trzy razy, każdy z nagłówkiem
„Pojedynek N: Drużyna A vs Drużyna B" (numer + obie nazwy drużyn wprost w
tytule) i raportem z `view.duelDifferentialFor(duel.slot)`. W przeciwnym
razie (każdy inny format, albo trójki bez zapisanych par) — jedno włączenie
partiału z raportem zbiorczym `view.differentials`, tak jak dotąd. Różnice
zbiorcze w trójkach z zapisanymi parami nie renderują się już wcale.

### `templates/fixture/_upcoming_preview.html.twig`
Ta sama gałąź warunku co wyżej, na `preview.duelDifferentialFor()` i
`fixture.duels` (fixture w tym kontekście to nadal prawdziwa encja
`LeagueFixture`, przekazana z `_match.html.twig` obok `preview`). `live`
zawsze `false` — poprzednia kolejka jest z definicji zamknięta.

## Dowód czerwonych testów

Przed implementacją każdy z poniższych testów kończył się błędem/failem:

```
1) MatchViewBuilderTest::testTrioDuelsComputeDifferentialsPerPairInsteadOfWholeSquad
   Error: Call to undefined method App\League\MatchView::duelDifferentialFor()

1) UpcomingMatchPreviewBuilderTest::testDuelDifferentialsFromPreviewIgnoreBenchPlayerOfBenchBoostSquad
   Error: Call to undefined method App\League\UpcomingMatchPreview::duelDifferentialFor()

1) MatchControllerTest::testTrioMatchShowsPerPairDifferentialsInsteadOfAggregate
   Failed asserting that ... body ... does not contain "Różnice w składach".
   (strona nadal renderowała starą, zbiorczą sekcję)
```

Dla reguły wag (`DifferentialAnalyzer`) czerwone było odwrotnie: dwa
**istniejące** testy (`testCaptaincyAloneWithEqualStartsDoesNotCreateMineEdge`
i lustrzany dla rywala) na starym kodzie były zielone, mimo że przypinały
dokładnie ten błąd, który zgłosił właściciel (opaska nie tworzy przewagi przy
równej liczbie drużyn) — zostały świadomie przepisane na
`testCaptainedPlayerCreatesMineEdgeDespiteEqualTeamCount` (i mirror), a nowy
test `testOpponentCaptainWithNoMinePresenceGivesFullWeightedEdgeAndPoints`
przypina dokładnie scenariusz z polecenia (waga 0/2, przewaga 2, bilans = 18).
Po zmianie kolumny `mineStartedBy`→`mineWeight` na starym kodzie ten test
kończył się błędem `Undefined property`, po wdrożeniu — przeszedł.

## Wynik testów

```
vendor/bin/phpunit
OK (323 tests, 1181 assertions)
```

Przyrost względem stanu wyjściowego (315 testów): +8 — 1 w
`MatchViewBuilderTest`, 2 w `UpcomingMatchPreviewBuilderTest`, 1 w
`MatchControllerTest`, 2 netto w `DifferentialAnalyzerTest` (kilka
przepisanych na miejscu, kilka nowych). `php bin/console lint:twig` na
wszystkich trzech zmienionych szablonach — OK. `php -l` na wszystkich
zmienionych plikach PHP — OK. `cache:clear` w środowisku `test` — OK (żaden
błąd w kontenerze DI po zmianie sygnatur konstruktorów `MatchView`/
`UpcomingMatchPreview`).

## Czego nie zweryfikowano wzrokowo

Zgodnie z poleceniem nie użyto przeglądarki ani zrzutów ekranu. W
szczególności nie widziałem na oczy:

- rzeczywistego odstępu między sześcioma kartami różnic w trzech blokach
  pojedynków na wąskim ekranie — polegam na tym, że układ `row row-cols-1
  row-cols-md-2` w `.col`/`.bucket` jest dokładnie tym samym, sprawdzonym już
  wcześniej układem co w zbiorczym widoku (dwie karty), tylko powtórzonym
  trzy razy; sam z siebie nie wprowadza nowego sposobu łamania siatki,
  a odstęp między kolejnymi blokami bierze się z istniejącej reguły `h2 {
  margin: 26px 0 8px }` + `.buckets { margin-bottom: 12px }`, tej samej,
  która dziś rozdziela sekcje strony (np. „Tabela kolejki" od „Różnice"),
- faktycznego renderu kolumny `1x–2x` (sprawdzone tylko przez asercje tekstowe
  w testach funkcjonalnych/twig-lint, nie przez podgląd w przeglądarce),
- responsywności na realnym wąskim viewport (brak przeglądarki — polegam na
  klasach Bootstrapa, nie na obserwacji).

## Uwaga poza zakresem

`App\League\DuelsScorer::assertPairsCoverTheSquads()` wymaga dziś zawsze
dokładnie 3 zapisanych pojedynków dla formatu trójki — bez nich cała strona
meczu (i podglądu, bo `MatchController` zawsze najpierw buduje `MatchView`)
kończy się wyjątkiem, niezależnie od tej zmiany. To sprawia, że „mecz trójek
bez zapisanych par" jest dziś nieosiągalny przez pełną stronę HTTP — obsłużyłem
to defensywnie tam, gdzie jest to realnie możliwe do przetestowania
(`UpcomingMatchPreviewBuilder`, który nie zależy od `DuelsScorer`) i
identycznie (ten sam warunek `usesDuels() && duels nie puste`) w
`MatchViewBuilder`/szablonie, żeby zachowanie było spójne, gdyby ten
wymóg kiedyś zniknął. Nie ruszałem `DuelsScorer` — to poza zakresem zlecenia
(„nie ruszasz liczenia wyniku").
