# Liga FPL 4x4 — projekt techniczny

Data: 2026-08-16
Status: zaakceptowany, gotowy do planu implementacji

## 1. Cel

Aplikacja webowa (Symfony + MySQL) obsługująca niestandardową ligę Fantasy
Premier League w formacie 4 na 4. Użytkownik ma stałą czwórkę drużyn z
oficjalnej gry; w każdej kolejce mierzy się z inną czwórką przeciwnika.
Aplikacja pobiera wyniki z oficjalnego API FPL, przelicza je na punkty ligowe
i pokazuje, co zdecydowało o wyniku meczu.

## 2. Zasady ligi

W kolejce startuje osiem drużyn — cztery moje i cztery rywala. Każda zdobywa
punkty dokładnie tak jak w oficjalnej grze, czyli **netto, po odjęciu kosztu
transferów** (hitów). Ósemka jest szeregowana malejąco i przeliczana na punkty
ligowe:

| Miejsce | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Punkty ligowe | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |

Przy remisie punkty dzielone są po równo między remisujących — dwie drużyny
ex aequo na miejscach 2–3 dostają po `(6 + 5) / 2 = 5,5`. Dzięki temu suma
punktów ligowych w meczu **zawsze wynosi 28**, co jest niezmiennikiem
weryfikowanym testem.

Wynik meczu to suma czterech moich wyników przeciw sumie czterech wyników
rywala. Wyższa suma wygrywa, równe sumy oznaczają remis.

## 3. Zakres

**W zakresie:**

- konfiguracja mojej stałej czwórki (4 ID z FPL),
- dodawanie meczu na kolejkę: numer kolejki + 4 ID rywala,
- pobieranie i przechowywanie wyników z FPL API,
- przeliczanie punktów ligowych i wyniku meczu,
- widok meczu z pełnymi składami (15 zawodników na drużynę),
- analiza różnic w składach między moją czwórką a czwórką rywala,
- bilans sezonu (rozegrane mecze, W/R/P).

**Poza zakresem:**

- pełny model ligi z wieloma drużynami, terminarzem i tabelą,
- uwierzytelnianie użytkowników (aplikacja lokalna, jeden użytkownik),
- wdrożenie na serwer zdalny,
- osobna logika chipów (chip jest tylko wyświetlany, punkty i tak przychodzą
  z API już z uwzględnionym efektem chipa).

## 4. Stos technologiczny

- PHP 8.4, Symfony 7 (skeleton webapp), Twig
- Doctrine ORM + migracje, MySQL (`mysql://root:***@127.0.0.1:3306/fpl`)
- Symfony HttpClient (scoped client do FPL API)
- PHPUnit + `MockHttpClient` i fixture'y JSON
- Bez frontendowego buildu — statyczny CSS w `public/`, rozwijanie składów
  natywnym elementem `<details>`

## 5. Model danych

### FplEntry

Słownik menedżerów z FPL. Jeden rekord na ID z gry, współdzielony między
kolejkami — ten sam rywal może wrócić w kolejnym meczu bez duplikowania danych.

| Pole | Typ | Uwagi |
|---|---|---|
| `id` | int, PK | ID z FPL, bez autoinkrementacji |
| `teamName` | string(255) | nazwa drużyny w grze |
| `firstName`, `lastName` | string(100) | dane menedżera |
| `startedEvent` | int, null | od której kolejki gra |
| `lastSyncedAt` | datetime_immutable, null | |

### Squad, SquadMember

`Squad` to czwórka: `name`, `isMine` (bool), `createdAt`. Dokładnie jedna
drużyna ma `isMine = true` — pilnuje tego walidator, bo MySQL nie ma
częściowych indeksów unikalnych.

`SquadMember` wiąże `Squad` z `FplEntry` przez `slot` (1–4). Unikalne pary
`(squad, slot)` oraz `(squad, fplEntry)`.

### Gameweek

Słownik kolejek z `bootstrap-static`: `id` (PK, numer kolejki), `name`,
`deadlineTime`, `finished`, `dataChecked`, `isCurrent`, `isNext`, `syncedAt`.

`dataChecked` to flaga FPL oznaczająca, że punkty są ostateczne (po
przyznaniu bonusów) — na niej opiera się zamrażanie snapshotów.

### PlTeam, Element

`PlTeam`: `id` (PK), `name`, `shortName` — kluby Premier League.

`Element`: `id` (PK, ID zawodnika w FPL), `webName`, `firstName`,
`secondName`, `elementType` (1–4: GK/DEF/MID/FWD), `plTeam` (FK), `syncedAt`.

### EntryGameweek

Snapshot wyniku jednego menedżera w jednej kolejce. Unikalne
`(fplEntry, gameweek)`.

| Pole | Typ | Uwagi |
|---|---|---|
| `fplEntry`, `gameweek` | FK | |
| `hasData` | bool | `false`, gdy API zwróciło 404 (menedżer nie grał) |
| `apiPoints` | int | `entry_history.points` bez interpretacji |
| `transfersCost` | int | `entry_history.event_transfers_cost` |
| `transfers` | int | `entry_history.event_transfers` |
| `pointsOnBench` | int | |
| `livePointsSum` | int, null | suma punktów jedenastki policzona z `live` |
| `pointsIncludeHit` | bool, null | wynik detekcji z sekcji 7 |
| `netPoints` | int | wartość używana przez `LeagueScorer` |
| `captainPoints` | int | punkty kapitana po mnożniku |
| `activeChip` | string(30), null | |
| `isFinal` | bool | snapshot zamrożony, nie odświeżamy |
| `syncedAt` | datetime_immutable | |

### EntryGameweekPick

Piętnaście wierszy do każdego snapshotu, kasowane kaskadowo. Unikalne
`(entryGameweek, position)`.

| Pole | Typ | Uwagi |
|---|---|---|
| `entryGameweek`, `element` | FK | |
| `position` | smallint | 1–15; 1–11 to jedenastka, 12–15 ławka |
| `multiplier` | smallint | 0 = ławka, 1 = gra, 2 = kapitan, 3 = potrójny |
| `isCaptain`, `isViceCaptain` | bool | |
| `rawPoints` | int | punkty zawodnika bez mnożnika |
| `effectivePoints` | int | `rawPoints * multiplier` |
| `minutes` | int | |

### Fixture

Mecz: `gameweek` (FK, unikalny — jeden mecz na kolejkę), `mySquad` (FK),
`opponentSquad` (FK), `createdAt`.

Punktów ligowych nie przechowujemy — są deterministyczną funkcją ośmiu
wartości `netPoints`, więc liczymy je w locie.

## 6. Integracja z FPL API

Bazowy adres: `https://fantasy.premierleague.com/api/`. API nie wymaga
uwierzytelniania dla używanych zasobów i nie ma oficjalnej dokumentacji.

| Zasób | Co z niego bierzemy |
|---|---|
| `bootstrap-static/` | `events[]` → `Gameweek`, `elements[]` → `Element`, `teams[]` → `PlTeam` |
| `entry/{id}/` | nazwa drużyny, imię i nazwisko menedżera → `FplEntry` |
| `entry/{id}/event/{gw}/picks/` | `entry_history`, `picks[]`, `active_chip` → `EntryGameweek` + `EntryGameweekPick` |
| `event/{gw}/live/` | `elements[].stats.total_points` i `.minutes` → punkty zawodników |

Każda odpowiedź mapowana jest na DTO w warstwie `FplApiClient`, żeby surowe
tablice z JSON-a nie wyciekały do reszty aplikacji. Timeout 5 s, jedna próba
ponowienia przy błędzie sieciowym.

## 7. Detekcja netto/brutto

Nie jest ustalone, czy `entry_history.points` zawiera już odjęty koszt
transferów — sezon 2026/27 startuje 21.08.2026 i endpoint `picks` zwraca
dziś 404, więc nie da się tego sprawdzić empirycznie przed implementacją.
Zamiast zgadywać, rozstrzygamy to przy każdym syncu:

1. policz `livePointsSum` = suma `effectivePoints` pozycji z `multiplier > 0`,
2. jeśli `apiPoints == livePointsSum` → `points` jest brutto,
   `netPoints = apiPoints - transfersCost`, `pointsIncludeHit = false`,
3. jeśli `apiPoints == livePointsSum - transfersCost` → `points` jest już
   netto, `netPoints = apiPoints`, `pointsIncludeHit = true`,
4. jeśli żadne nie pasuje (rozbieżność z innego powodu, np. auto-podmiany
   w trakcie trwania kolejki) → `netPoints = apiPoints - transfersCost`
   gdy poprzednie snapshoty wskazywały brutto, w przeciwnym razie
   `netPoints = apiPoints`; rozbieżność trafia do logu.

Źródłem prawdy dla sumy pozostaje `entry_history.points`, bo to FPL nalicza
auto-podmiany i kapitana. Dane z `live` służą do rozbicia na zawodników
i do powyższej detekcji, nigdy do samodzielnego wyliczenia wyniku drużyny.

## 8. Synchronizacja

`FplSyncService` odświeża snapshot według jednej reguły:

- snapshot z `isFinal = true` nie jest już nigdy pobierany,
- snapshot z `syncedAt` młodszym niż 5 minut jest pomijany,
- w pozostałych przypadkach pobieramy `picks` i zapisujemy snapshot;
  `isFinal` ustawiamy, gdy `Gameweek.dataChecked = true`.

Punkty zawodników (`event/{gw}/live/`) pobierane są raz na kolejkę dla całej
ósemki, więc pełne odświeżenie meczu to 9 zapytań, nie 16. Słowniki (kolejki,
zawodnicy, kluby) odświeżane są raz na dobę.

Sync uruchamia się przy wejściu na widok meczu. Gdy API nie odpowiada,
renderujemy ostatni zapisany snapshot z banerem informującym, ile ma minut —
brak sieci nie może zablokować widoku.

Ta sama logika dostępna jest jako komenda `app:fpl:sync [--gw=N] [--force]`,
do ewentualnego podpięcia pod crona.

## 9. Punktacja — `LeagueScorer`

Wejście: osiem par (menedżer, `netPoints`, strona meczu). Algorytm:

1. sortuj malejąco po `netPoints`,
2. przypisz pozycje 1–8; punkty ligowe pozycji `i` to `8 - i`,
3. dla grupy remisujących zajmującej pozycje `a..b` przypisz każdemu
   `((8 - a) + (8 - b)) / 2`,
4. zsumuj punkty ligowe po stronach; wyższa suma wygrywa.

Wynik: uszeregowana lista wierszy (menedżer, strona, punkty FPL, pozycja,
punkty ligowe), suma moja, suma rywala, rozstrzygnięcie W/R/P i margines.

Menedżer bez danych (`hasData = false`) wchodzi do rankingu z zerem punktów
FPL i jest oznaczony w widoku.

Punkty ligowe nie trafiają do bazy — istnieją tylko jako wartość
zmiennoprzecinkowa w obiekcie wyniku i są formatowane w widoku z jednym
miejscem po przecinku. Połówki pochodzą wyłącznie z remisów.

## 10. Różnice w składach — `DifferentialAnalyzer`

Porównanie jedenastek (`multiplier > 0`) mojej czwórki i czwórki rywala.
Dla każdego zawodnika agregujemy po stronach: liczbę drużyn, które go
wystawiły, liczbę drużyn trzymających go na ławce, i sumaryczny wkład
punktowy z uwzględnieniem mnożników kapitańskich.

Trzy koszyki na wyjściu:

- **tylko moi** — wystawieni po mojej stronie, przez nikogo u rywala,
- **tylko rywala** — odwrotnie,
- **wspólni** — wystawieni po obu stronach.

Na górze bilans: suma wkładu „tylko moich" minus suma „tylko rywala".
Zawodnicy z ławki nie liczą się do bilansu (nie dają punktów), ale są
pokazywani jako adnotacja przy koszykach — informacja, że rywal przesiedział
kolejkę na Haalandzie, jest częścią historii meczu.

## 11. Interfejs

| Ścieżka | Zawartość |
|---|---|
| `GET /` | bilans sezonu (W/R/P), lista meczów, wskazanie aktualnej kolejki |
| `GET\|POST /settings` | konfiguracja mojej czwórki: nazwa + 4 ID |
| `GET\|POST /fixture/new` | formularz: numer kolejki + 4 ID rywala + opcjonalna nazwa jego drużyny |
| `GET /fixture/{gw}` | widok meczu |

Widok meczu składa się z trzech sekcji:

1. nagłówek z wynikiem (`MOJA 17 : 11 RYWAL`) i statusem kolejki
   (trwa / zakończona / dane ostateczne),
2. tabela ośmiu drużyn: miejsce, nazwa, punkty FPL netto, koszt transferów,
   chip, punkty kapitana, punkty ławki, punkty ligowe; wiersze mojej strony
   wyróżnione,
3. sekcja różnic w składach, a pod nią rozwijane pełne składy 15 zawodników
   każdej drużyny (pozycja, klub, punkty, oznaczenie kapitana i ławki).

ID rywala walidowane są przy zapisie formularza zapytaniem do `entry/{id}/` —
literówka nie może trafić do bazy. Przy okazji pobieramy nazwę drużyny
i menedżera.

## 12. Testy

- **Jednostkowe `LeagueScorer`**: kolejność bez remisów, remis dwóch drużyn,
  remis trzech i czterech, remis wszystkich ośmiu, niezmiennik sumy 28,
  rozstrzygnięcie W/R/P.
- **Jednostkowe detekcji netto/brutto**: obie zgodne interpretacje oraz
  przypadek rozbieżny z fallbackiem.
- **Jednostkowe `DifferentialAnalyzer`**: rozdział na trzy koszyki,
  zawodnik u obu stron z różnym mnożnikiem kapitańskim, zawodnik wyłącznie
  na ławkach, bilans netto.
- **Integracyjne `FplSyncService`** na `MockHttpClient`: pierwszy sync,
  pominięcie w oknie TTL, zamrożenie po `dataChecked`, 404 dla menedżera,
  awaria sieci przy istniejącym snapshocie.
- **Funkcjonalne**: render widoku meczu z danych w bazie, walidacja
  formularza nowego meczu.

Fixture'y JSON na start konstruowane są ręcznie w kształcie zgodnym ze
schematem odpowiedzi API. Po pierwszej rozegranej kolejce podmieniamy je na
zapisane prawdziwe odpowiedzi.

## 13. Ryzyka

- **Sezon nie wystartował** (pierwszy deadline 21.08.2026). `entry/{id}/event/1/picks/`
  zwraca 404, `event/1/live/` pustą listę. Cała aplikacja musi być
  zbudowana i przetestowana na fixture'ach; pierwsza weryfikacja na żywych
  danych możliwa po 22.08.2026.
- **API bez gwarancji stabilności** — brak oficjalnej dokumentacji i wersjonowania.
  Mapowanie odizolowane w `FplApiClient` i DTO, żeby zmiana kształtu odpowiedzi
  dotykała jednego miejsca.
- **Auto-podmiany w trakcie kolejki** nie są naliczane na bieżąco, więc suma
  z `live` może chwilowo odbiegać od `entry_history.points`. Dlatego źródłem
  prawdy dla wyniku drużyny jest zawsze `entry_history`.
