# UCL Manager League — projekt (spec)

Data: 2026-09-03
Status: zatwierdzony przez właściciela projektu

## 1. Cel

Aplikacja prowadząca prywatne rozgrywki w formacie Ligi Mistrzów UEFA, w których
o wyniku meczu decydują punkty uczestników zdobyte w oficjalnej grze fantasy na
`gaming.uefa.com`.

Inspiracja: liga „ALM" w projekcie `/Users/gladki/projects/fantasy` (Symfony 6.4,
`LEAGUE_ID=4`). Nowa aplikacja to osobne repozytorium i osobny model — przenosimy
reguły rozgrywek, nie kod.

## 2. Reguły rozgrywek

### 2.1 Uczestnicy i kluby

Edycja rozgrywek ma **36 klubów** — te same, które grają w danym sezonie
w prawdziwej Lidze Mistrzów.

Edycja działa w jednym z dwóch trybów:

- **SOLO** — jeden uczestnik na klub (36 osób).
- **PAIRS** — dwóch uczestników na klub (72 osoby); wynik klubu to suma punktów
  obu.

Tryb jest wyłącznie właściwością edycji. W kodzie różnica sprowadza się do tego,
że wynik klubu w kolejce to suma punktów wszystkich przypisanych do niego
uczestników — jednego albo dwóch. Nie ma osobnych ścieżek obliczeniowych.

Przypisanie uczestników do klubów wykonuje **admin ręcznie**. Nie ma losowania,
draftu ani samodzielnej rejestracji.

### 2.2 Faza ligowa

8 kolejek, terminarz **kopiowany 1:1 z prawdziwej Ligi Mistrzów** (admin wkleja
go raz na sezon). Jedna wspólna tabela 36 klubów.

Wynik meczu: klub z większą liczbą punktów fantasy wygrywa. Punktacja 3 / 1 / 0.

Kolejność w tabeli, malejąco:

1. punkty (3/1/0),
2. różnica punktów fantasy (zdobyte − stracone),
3. punkty fantasy zdobyte,
4. nazwa klubu rosnąco (gwarantuje determinizm — nigdy remis w sortowaniu).

### 2.3 Faza pucharowa

**Każda runda pucharowa to jeden mecz, nie dwumecz.** Powód jest twardy: UEFA
Fantasy publikuje punkty per matchday, a w fazie pucharowej jeden matchday
obejmuje oba spotkania prawdziwego dwumeczu. Nie istnieje osobna liczba punktów
za pierwszy i drugi mecz.

Kolejki w edycji, łącznie 13:

| Nr | Etap | Meczów |
|----|------|--------|
| 1–8 | faza ligowa | 18 na kolejkę |
| 9  | baraże (PLAYOFF) | 8 |
| 10 | 1/8 finału (R16) | 8 |
| 11 | ćwierćfinały (QF) | 4 |
| 12 | półfinały (SF) | 2 |
| 13 | finał (FINAL) | 1 |

Kwalifikacja po fazie ligowej:

- miejsca 1–8: bezpośrednio do 1/8 finału,
- miejsca 9–24: baraże,
- miejsca 25–36: odpadają.

Pary barażowe (jak w UEFA): 9–24, 10–23, 11–22, 12–21, 13–20, 14–19, 15–18,
16–17. Sloty nazywamy `PO1`…`PO8` w tej właśnie kolejności.

Drabinka 1/8 finału — deterministyczne odwzorowanie zasady UEFA (rozstawieni 1–2
trafiają na zwycięzców par z udziałem 15–18, 3–4 na pary z 13–14/19–20, 5–6 na
11–12/21–22, 7–8 na 9–10/23–24). Zamiast losowania wewnątrz każdej pary
stosujemy stałą regułę „lepiej rozstawiony dostaje parę o wyższym numerze":

| Slot | Gospodarz | Gość |
|------|-----------|------|
| R16-1 | 1 | zwycięzca PO8 (16–17) |
| R16-2 | 2 | zwycięzca PO7 (15–18) |
| R16-3 | 3 | zwycięzca PO6 (14–19) |
| R16-4 | 4 | zwycięzca PO5 (13–20) |
| R16-5 | 5 | zwycięzca PO4 (12–21) |
| R16-6 | 6 | zwycięzca PO3 (11–22) |
| R16-7 | 7 | zwycięzca PO2 (10–23) |
| R16-8 | 8 | zwycięzca PO1 (9–24) |

Dalsza drabinka jest stała i trzyma rozstawionych 1 i 2 po przeciwnych stronach:

- QF1 = R16-1 vs R16-8, QF2 = R16-4 vs R16-5,
- QF3 = R16-2 vs R16-7, QF4 = R16-3 vs R16-6,
- SF1 = QF1 vs QF2, SF2 = QF3 vs QF4,
- FINAL = SF1 vs SF2.

W każdym meczu pucharowym gospodarzem jest klub **wyżej rozstawiony**, gdzie
rozstawienie = miejsce w końcowej tabeli fazy ligowej. Gospodarz nie daje
przewagi punktowej — służy wyłącznie prezentacji.

**Remis w meczu pucharowym: awansuje klub wyżej rozstawiony.**

## 3. Źródło punktów

Publicznego API `gaming.uefa.com` nie ma. Punkty trafiają do aplikacji przez
**ręczne wklejenie leaderboardu** przez admina.

Dopasowanie idzie po **nazwie drużyny fantasy na gaming.uefa.com**
(`Manager.uefaTeamName`), która musi być unikalna w obrębie edycji.

Przebieg importu:

1. Admin wybiera kolejkę i wkleja treść leaderboardu.
2. Aplikacja parsuje i pokazuje **podgląd dopasowania**: dopasowani uczestnicy
   z punktami, uczestnicy nieodnalezieni we wklejce, pozycje we wklejce bez
   odpowiednika w edycji.
3. Admin zatwierdza — dopiero wtedy zapis i przeliczenie.

Podgląd nie jest ozdobnikiem: przy dopasowaniu po nazwie jedna literówka po
stronie UEFA cicho zeruje komuś kolejkę.

Parsowanie jest schowane za interfejsem `LeaderboardParser` w dwóch
implementacjach:

- `UefaHtmlLeaderboardParser` — HTML skopiowany ze strony UEFA, czytany przez
  DomCrawler (nie regexem).
- `TsvLeaderboardParser` — `nazwa<TAB>punkty` w wierszach; awaryjne wejście na
  wypadek zmiany HTML-a UEFA w środku sezonu.

Ewentualne automatyczne pobieranie danych w przyszłości to trzecia implementacja
tego samego interfejsu, bez zmian w reszcie aplikacji.

Dodatkowo admin może ręcznie skorygować pojedynczy wynik; taki wpis jest
oznaczony jako `MANUAL`.

## 4. Przeliczanie — założenie nadrzędne

`ManagerRoundScore` (punkty jednego uczestnika za jedną kolejkę) jest **jedynym
źródłem prawdy**. Wyniki meczów i cała tabela są z niego wyprowadzane.

`StandingsCalculator` przy każdym uruchomieniu **kasuje i buduje od zera**
wyniki meczów oraz migawki tabeli dla całej edycji. Import jest dzięki temu
idempotentny: można go powtórzyć, poprawić pojedynczy wynik w piątej kolejce albo
dograć zapomnianego uczestnika — stan zawsze się zgadza.

To jest świadoma różnica wobec projektu `fantasy`, gdzie
`LeagueService::calculateResults()` inkrementuje tabelę i przez to ponowny import
tej samej kolejki podwaja punkty.

## 5. Model danych

- **Competition** — edycja: nazwa, slug, sezon, tryb (`SOLO`/`PAIRS`), numer
  bieżącej kolejki.
- **Club** — klub w edycji: nazwa, nazwa skrócona, plik herbu.
- **Manager** — uczestnik: nick, klub, `uefaTeamName` (unikalna w edycji),
  opcjonalny link do drużyny UEFA.
- **Round** — kolejka: numer 1–13, etap (`LEAGUE`, `PLAYOFF`, `R16`, `QF`, `SF`,
  `FINAL`), znacznik zamknięcia.
- **Fixture** — mecz: kolejka, gospodarz, gość, wyliczone punkty obu stron,
  slot drabinki (`PO1`, `R16-3`, `QF2`, `SF1`, `F`) dla rund pucharowych.
- **ManagerRoundScore** — uczestnik, kolejka, punkty, źródło (`IMPORT`/`MANUAL`).
  Unikalne (uczestnik, kolejka).
- **StandingRow** — migawka tabeli po danej kolejce: klub, rozegrane, W/R/P,
  punkty fantasy zdobyte i stracone, różnica, punkty 3/1/0, pozycja, pozycja
  poprzednia. Unikalne (kolejka, klub).
- **User** — wyłącznie konto admina.

Logika, którą da się policzyć bez bazy, mieszka w klasach czystych
(`StandingsTable`, `BracketDraw`) i jest testowana bez Doctrine. Warstwa
Doctrine tylko je karmi i zapisuje wynik.

## 6. Dostęp

- **Publiczne, bez logowania:** tabela, terminarz i wyniki kolejki, karta meczu
  z rozbiciem na punkty poszczególnych uczestników, karta klubu, drabinka
  pucharowa, statystyki.
- **Panel admina** (zwykłe logowanie formularzem, bez 2FA, bez rejestracji,
  bez resetu hasła): edycje, kluby, uczestnicy i ich przypisanie, wklejenie
  terminarza, import punktów, korekta wyniku, zamknięcie kolejki, wygenerowanie
  drabinki.

Wiele edycji żyje w jednej bazie (np. równolegle edycja SOLO i PAIRS), wybierane
slugiem w adresie.

## 7. Stack

Symfony 7.4 LTS, PHP 8.3+ (lokalnie 8.4), Doctrine ORM 3 z migracjami, Twig,
Tabler jako warstwa wizualna, MySQL, PHPUnit.

Ryzyko: `kevinpapst/tabler-bundle` w wersji z projektu `fantasy` celuje w Symfony
6.4. Jeśli nie wspiera 7.4, Tabler wchodzi jako zwykłe assety CSS — bez wpływu na
model i reguły.

## 8. Świadomie poza zakresem

- Konta i logowanie uczestników.
- Automatyczne pobieranie punktów z UEFA.
- Losowanie / draft klubów.
- Chipy i „jokery" znane z projektu `fantasy`.
- Terminarz generowany przez aplikację (zawsze kopiowany z prawdziwej LM).
