From 300cb8aeafc290e5599b6aee490085f55a9e844f Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 18 Aug 2026 16:29:10 +0200 Subject: [PATCH] Tiebreak ved likt resultat: countback eller laveste HCP (ADR-089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organisator velger metode fritt i Oppsett, uavhengig for vinner og resten av feltet. Countback er kaskaderende siste 9->6->3->1 hull i siste runde. Påvirker aldri Cut-grensen -- strukturelt håndhevet, ikke bare avtalt ved konvensjon. Migrasjon 085 lagt til, men IKKE anvendt mot ekte teecup_db ennå -- venter på bekreftelse. --- 085_tournament_tiebreak.sql | 36 ++++ ARCHITECTURE_DECISIONS.md | 77 +++++++ CHANGELOG.md | 29 +++ app/routers/individual_tournaments.py | 171 +++++++++++++++- app/routers/tournaments.py | 18 +- .../individual-tournament-detail.tsx | 124 +++++++++++- tests/test_tiebreak.py | 189 ++++++++++++++++++ 7 files changed, 634 insertions(+), 10 deletions(-) create mode 100644 085_tournament_tiebreak.sql create mode 100644 tests/test_tiebreak.py diff --git a/085_tournament_tiebreak.sql b/085_tournament_tiebreak.sql new file mode 100644 index 0000000..1193fa7 --- /dev/null +++ b/085_tournament_tiebreak.sql @@ -0,0 +1,36 @@ +-- ===================================================================== +-- TeeCup — migrasjon 085 +-- Tiebreak ved likt resultat i individuelle turneringer (brutto/netto/ +-- stableford): countback eller laveste HCP, valgt av organisator +-- ===================================================================== +-- Bruker spurte, etter å ha fått Cut-funksjonen (ADR-087): "Ved likt +-- resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/ +-- siste hull plasseres foran, eller deles plasseringene. Her kan svaret +-- være forskjellig for vinner og resten av feltet." +-- +-- Design bekreftet med bruker (tre spørsmål): +-- - Metoden er IKKE fastlåst i koden -- "Dette skal defineres av +-- arrangøren ved oppsett av turneringen" -- for BÅDE vinneren og +-- resten av feltet, hver for seg (samme svar begge steder, altså to +-- uavhengige innstillinger, ikke én felles). +-- - Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI +-- selve Cut-grensen (Cut sin "topp N og delt plass" fra ADR-087 +-- forblir uendret -- fullt delt plass på grensen, uansett hva +-- tiebreak-innstillingen sier). +-- +-- `tiebreak_winner_method`/`tiebreak_field_method`: 'none' (uendret +-- oppførsel -- delt plassering, dagens eneste faktiske adferd) er +-- default, ingen regresjon for eksisterende turneringer. 'countback' = +-- kaskaderende "matching cards" (siste 9 -> siste 6 -> siste 3 -> siste +-- hull i turneringens SISTE runde, standard klubbgolf-metode -- ingen +-- egen dybde-parameter, kaskaden er alltid full). 'lowest_hcp' = laveste +-- handicap-indeks vinner uavgjortheten. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE tournament + ADD COLUMN tiebreak_winner_method text NOT NULL DEFAULT 'none' + CHECK (tiebreak_winner_method IN ('none', 'countback', 'lowest_hcp')), + ADD COLUMN tiebreak_field_method text NOT NULL DEFAULT 'none' + CHECK (tiebreak_field_method IN ('none', 'countback', 'lowest_hcp')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 1b8b13f..7758cfa 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9101,6 +9101,83 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK. --- +## ADR-089: Tiebreak ved likt resultat -- countback eller laveste HCP, organisator velger (2026-08-18) + +Bruker, etter Cut (ADR-087): "Dersom jeg ønsker at f.eks Tied 12. plass +skal klare cut'en, hvordan løser vi det? ... En annen ting: Ved likt +resultat ... Skal laveste hcp plasseres foran, beste siste 18/9/6/3/ +siste hull plasseres foran, eller deles plasseringene. Her kan svaret +være forskjellig for vinner og resten av feltet." + +**Første spørsmål var allerede løst** -- Cut sin "topp N og delt plass" +(ADR-087) bruker RANGERING, ikke et hodetall, som cut-grense: alle med +samme til-par som grenseplassen slipper gjennom, uansett hvor mange. +Ingen endring nødvendig, kun bekreftet med et testeksempel. + +**Andre spørsmål var en ekte, udesignet mangel** -- ingen tiebreak-logikk +fantes i det hele tatt, delte plasseringer ble ALLTID vist som delt +("T1"), for vinneren og resten av feltet likt. Design avklart med +bruker (tre spørsmål): + +- **Metoden er IKKE fastlåst i koden** -- "Dette skal defineres av + arrangøren ved oppsett av turneringen", for BÅDE vinneren og resten + av feltet, som TO UAVHENGIGE innstillinger (kan være ulik metode for + hver). +- **Tiebreak påvirker KUN sluttresultatet/plasseringsvisningen, ALDRI + selve Cut-grensen** -- Cut sin fulle delte plass (ADR-087) forblir + uendret uansett tiebreak-innstilling. + +**Migrasjon 085:** `tournament.tiebreak_winner_method`/ +`tiebreak_field_method` (`'none'` default -- uendret oppførsel, ingen +regresjon -- `'countback'`, `'lowest_hcp'`). + +**Backend (`app/routers/individual_tournaments.py`):** +- `_countback_sort_keys`: kaskaderende "matching cards"-countback -- + siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens SISTE + runde (standard klubbgolf-metode, ingen egen dybde-innstilling å + konfigurere -- kaskaden er alltid full). Python sin tuple- + sammenligning gir selve kaskaden gratis: `(siste9, siste6, siste3, + siste1)` sammenlignet elementvis er nøyaktig hvordan en reell + countback fungerer. Gjenbruker `allocate_over_played_holes`/ + `stableford_points_for_hole` fra `handicap_engine.py` (samme + primitiver som resten av scoringen) for netto/Stableford per hull. +- `_lowest_hcp_sort_keys`: `tournament_participant.handicap_index_ + snapshot`, manglende indeks sorteres sist (kan aldri vinne en + tiebreak ved å mangle data). +- Begge integrert i `_attach_stroke_play_columns` sin eksisterende + POS-løkke: en tidligere fullt delt gruppe får nå sin tiebreak-metode + anvendt (vinner-metoden for rank 1, felt-metoden ellers), tildelt med + samme "vanlig konkurranse-rangering"-algoritme REKURSIVT innad i + gruppen (`_assign_ranks_within_group`) -- resterende uavgjorte ETTER + tiebreak (samme nøkkel også der) deler fortsatt plass innbyrdes, + tiebreak garanterer ikke en total orden, den reduserer bare hvor ofte + uavgjort faktisk skjer. +- **Cut-grensen upåvirket, håndhevet strukturelt, ikke bare ved + konvensjon:** tiebreak-koden er portet bak `max_sequence is None` -- + `apply_cut` (ADR-087) sender ALLTID en satt `max_sequence`, så + tiebreak-grenen kan aldri kjøre derfra, uansett hvilken metode som er + konfigurert. Egen test bekrefter dette eksplisitt (countback slått på + OG en ekte fullt uavgjort cut-grense -- begge slipper likevel gjennom). + +**Frontend:** ny `TiebreakCard` i Oppsett-fanen, rett under `CutCard`, +samme "vanlig innstillingskort"-mønster (håndkodet, ikke V0 -- liten +tilføyelse til allerede håndkodede innstillingskort i samme fil, se +[[feedback_frontend_via_v0]]) -- to uavhengige nedtrekk (vinner/felt), +hver med "Delt plassering"/"Countback"/"Laveste HCP". + +**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 4 nye +tester i `tests/test_tiebreak.py` (countback løser en vinner-uavgjort +på bedre back9, laveste HCP løser en felt-uavgjort MEN lar en klar +vinner stå urørt, `'none'` beholder uendret delt-plassering-oppførsel, +og en eksplisitt cut-grense-test med countback slått PÅ som likevel +ikke bryter en ekte uavgjort cut-grense) -- 135/135 grønt (opp fra 131), +kjørt via `scripts/run_backend_tests.sh` (egen scratch-database). + +**Rullet ut:** venter på bekreftelse (migrasjon 085 mot ekte `teecup_db` +krever egen bekreftelse, se CLAUDE.md). + +--- + ## Utviklingsplan (rekkefølge) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) diff --git a/CHANGELOG.md b/CHANGELOG.md index 302f979..352fbbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12708,3 +12708,32 @@ Neste steg: **Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 084 kjørt mot ekte `teecup_db`, deretter `docker compose build teecup_api teecup_frontend && up -d` for begge, rene logger, 200 OK. + +117. **Tiebreak ved likt resultat -- 2026-08-18 (ADR-089).** Bruker, + etter Cut: bekreftet at "topp N og delt plass" allerede fungerer + riktig for "mange kan dele 12.-plassen" (rangering, ikke hodetall). + Ekte gap derimot: ingen tiebreak-logikk fantes -- delte plasseringer + ble alltid vist delt, for vinner OG felt likt. Bruker: metoden skal + "defineres av arrangøren ved oppsett", som TO uavhengige + innstillinger (vinner/felt kan ha ulik metode), og skal ALDRI + påvirke Cut-grensen -- kun sluttresultatet. + + Migrasjon 085 (`tournament.tiebreak_winner_method`/`tiebreak_field_ + method`, default `'none'`). Ny `_countback_sort_keys` (kaskaderende + siste 9->6->3->1 hull i siste runde, standard klubbgolf-metode, + Python tuple-sammenligning gir kaskaden gratis) og `_lowest_hcp_ + sort_keys`, begge integrert i den eksisterende POS-løkken i + `_attach_stroke_play_columns`. Cut-uavhengigheten er strukturelt + håndhevet (portet bak `max_sequence is None`, ikke bare avtalt ved + konvensjon) -- `apply_cut` kan aldri trigge tiebreak-grenen uansett + konfigurasjon. Ny `TiebreakCard` i Oppsett, samme mønster som + `CutCard`. + + **Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 4 nye + backend-tester i `tests/test_tiebreak.py` (countback, laveste HCP, + uendret `'none'`-oppførsel, og en eksplisitt bekreftelse på at Cut + IKKE påvirkes selv med countback slått på) -- 135/135 grønt (opp fra + 131). + + **Rullet ut:** venter på bekreftelse (migrasjon 085 mot ekte + `teecup_db` krever egen bekreftelse, se CLAUDE.md). diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 9d30497..456cffc 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -1596,7 +1596,9 @@ async def _compute_individual_standings( hver entry (`gross_total`/`net_total`/`stableford_total`/`position`) som faktisk er meningsfullt å lese videre.""" tournament = await conn.fetchrow( - "SELECT format_type, scoring_method FROM tournament WHERE id = $1", tournament_id + "SELECT format_type, scoring_method, tiebreak_winner_method, tiebreak_field_method " + "FROM tournament WHERE id = $1", + tournament_id, ) if tournament is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") @@ -1643,7 +1645,15 @@ async def _compute_individual_standings( # deltaker med KUN 18 hull spilt (lavere rå sum) rangerte foran # deltakere med -5 til par over 27-36 hull, kun fordi rått # slagtall er mindre jo færre hull man har spilt. - await _attach_stroke_play_columns(conn, tournament_id, method, entries, max_sequence=max_sequence) + await _attach_stroke_play_columns( + conn, + tournament_id, + method, + entries, + max_sequence=max_sequence, + tiebreak_winner_method=tournament["tiebreak_winner_method"], + tiebreak_field_method=tournament["tiebreak_field_method"], + ) # Cut (2026-08-18): kuttede spillere vises samlet BAKERST, som en # egen seksjon -- ALDRI innimellom fortsatt-med-spillere basert på # frossent til-par-tall alene (en kuttet spiller sin score endrer @@ -1788,8 +1798,128 @@ def _to_par_tone(v: int) -> str: return "even" +async def _lowest_hcp_sort_keys(conn, participant_ids: list[str]) -> dict[str, tuple]: + """Tiebreak 'lowest_hcp' (migrasjon 085) -- laveste handicap-indeks + vinner. Manglende indeks sorteres SIST (kan aldri vinne en tiebreak + ved å mangle data).""" + rows = await conn.fetch( + "SELECT id::text AS id, handicap_index_snapshot::float AS hcp " + "FROM tournament_participant WHERE id = ANY($1::uuid[])", + participant_ids, + ) + return {r["id"]: (r["hcp"] if r["hcp"] is not None else float("inf"),) for r in rows} + + +async def _countback_sort_keys(conn, tournament_id: str, method: str, participant_ids: list[str]) -> dict[str, tuple]: + """Tiebreak 'countback' (migrasjon 085) -- kaskaderende "matching + cards": siste 9 -> siste 6 -> siste 3 -> siste hull i turneringens + SISTE runde (standard klubbgolf-metode, ingen egen dybde-innstilling + -- kaskaden er alltid full). Python sin tuple-sammenligning gjør selve + kaskaden gratis: (siste9, siste6, siste3, siste1), sammenlignet + elementvis i rekkefølge, akkurat slik en reell countback fungerer. + + ASCENDING sortering, lavere er alltid bedre -- Stableford-poeng + negeres for å passe samme konvensjon (flere poeng er bedre der). + + Bruker turneringens ABSOLUTT SISTE runde (høyest sequence) som + grunnlag, uansett hvor mange runder DENNE deltakeren faktisk har + spilt -- en ekte uavgjort sluttstilling forutsetter normalt at begge + har spilt like mange runder. Mangler en deltaker data for den runden + (har ikke spilt den ennå), får de en nøytral midtverdi (0,0,0,0) -- + ingen krasj, bare ingen reell tiebreak-fordel/ulempe.""" + last_round = await conn.fetchrow( + "SELECT id, course_id FROM tournament_round WHERE tournament_id = $1 ORDER BY sequence DESC LIMIT 1", + tournament_id, + ) + if last_round is None: + return {pid: (0, 0, 0, 0) for pid in participant_ids} + + holes_meta = await conn.fetch( + "SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", + last_round["course_id"], + ) + par_by_hole = {h["hole_number"]: h["par"] for h in holes_meta} + all_18_si = [h["stroke_index"] for h in holes_meta] + + keys: dict[str, tuple] = {} + for pid in participant_ids: + rp = await conn.fetchrow( + "SELECT id, playing_handicap FROM tournament_round_participant " + "WHERE tournament_round_id = $1 AND tournament_participant_id = $2", + last_round["id"], + pid, + ) + if rp is None: + keys[pid] = (0, 0, 0, 0) + continue + hole_rows = await conn.fetch( + "SELECT hole_number, gross_strokes FROM tournament_round_hole " + "WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL ORDER BY hole_number", + rp["id"], + ) + if not hole_rows: + keys[pid] = (0, 0, 0, 0) + continue + + played_numbers = [h["hole_number"] for h in hole_rows] + gross_by_hole = {h["hole_number"]: h["gross_strokes"] for h in hole_rows} + received = [0] * len(played_numbers) + if method != "stroke_gross" and rp["playing_handicap"] is not None: + received = allocate_over_played_holes(rp["playing_handicap"], all_18_si, played_numbers) + + per_hole_values = [] + for hole_number, strokes_received in zip(played_numbers, received): + gross = gross_by_hole[hole_number] + par = par_by_hole[hole_number] + if method == "stableford": + per_hole_values.append(stableford_points_for_hole(par, gross, strokes_received)) + else: + per_hole_values.append((gross - strokes_received) - par) + + def suffix_sum(n: int, values: list[int] = per_hole_values) -> int: + return sum(values[-n:]) if values else 0 + + last9, last6, last3, last1 = suffix_sum(9), suffix_sum(6), suffix_sum(3), suffix_sum(1) + keys[pid] = (-last9, -last6, -last3, -last1) if method == "stableford" else (last9, last6, last3, last1) + return keys + + +def _assign_ranks_within_group( + entries: list[LeaderboardEntry], indices: list[int], sort_keys: dict[str, tuple], base_rank: int +) -> None: + """Tildeler posisjon/rank til en (tidligere fullt uavgjort) gruppe + ETTER at en tiebreak-nøkkel er beregnet -- samme "vanlig konkurranse- + rangering, uavgjort deler plass"-algoritme som hovedløkken under, + men startet på `base_rank` i stedet for 1. Gjenværende uavgjorte + (samme nøkkel også etter tiebreak) deler fortsatt plass innbyrdes -- + tiebreak GARANTERER ikke en total orden, den reduserer bare hvor ofte + uavgjort faktisk skjer.""" + ordered = sorted(indices, key=lambda idx: sort_keys[entries[idx].tournament_participant_id]) + n = len(ordered) + i = 0 + while i < n: + j = i + key_i = sort_keys[entries[ordered[i]].tournament_participant_id] + while j < n and sort_keys[entries[ordered[j]].tournament_participant_id] == key_i: + j += 1 + rank = base_rank + i + tied = (j - i) > 1 + for k in range(i, j): + idx = ordered[k] + entries[idx].position = f"T{rank}" if tied else str(rank) + entries[idx].rank = rank + entries[idx].is_leader = rank == 1 + i = j + + async def _attach_stroke_play_columns( - conn, tournament_id: str, method: str, entries: list[LeaderboardEntry], max_sequence: int | None = None + conn, + tournament_id: str, + method: str, + entries: list[LeaderboardEntry], + max_sequence: int | None = None, + tiebreak_winner_method: str = "none", + tiebreak_field_method: str = "none", ) -> None: """Augusta-stil resultattavle (2026-08-04, stroke-play-leaderboard.tsx): POS/TODAY/THRU/TOTAL/R1-Rn. Muterer `entries` (allerede riktig sortert av @@ -1799,6 +1929,17 @@ async def _attach_stroke_play_columns( per-runde nedbrytning, og posisjon-med-uavgjort-håndtering. `max_sequence` (2026-08-18, Cut) -- se `_compute_individual_standings`. + + `tiebreak_winner_method`/`tiebreak_field_method` (migrasjon 085, + 2026-08-18) -- BEVISST kun brukt når `max_sequence is None`. Cut- + beregningen (apply_cut) kaller alltid denne funksjonen MED en satt + max_sequence, og skal ALDRI la tiebreak påvirke hvem som slipper + gjennom (bruker bekreftet eksplisitt: "Kun sluttresultat, ikke cut") + -- cut-grensen forblir ren rangering med full delt plass uansett. + Den vanlige leaderboard-visningen (`individual_leaderboard`) sender + aldri max_sequence, så tiebreak gjelder der -- inkludert midt i + turneringen, ikke bare ved siste runde ferdigspilt (samme "vis det + beste vi vet akkurat nå"-filosofi som resten av leaderboardet). """ rounds_meta = await conn.fetch( """ @@ -1965,10 +2106,26 @@ async def _attach_stroke_play_columns( rank = i + 1 tied = (j - i) > 1 not_started_group = key_i == NO_SCORE - for k in range(i, j): - entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) - entries[k].is_leader = rank == 1 and not not_started_group - entries[k].rank = None if not_started_group else rank + + # Tiebreak (migrasjon 085) -- kun for en FAKTISK uavgjort gruppe, + # aldri for cut-beregningen (se _attach_stroke_play_columns sin + # docstring for hvorfor max_sequence is None er selve porten). + tiebreak_method = "none" + if tied and not not_started_group and max_sequence is None: + tiebreak_method = tiebreak_winner_method if rank == 1 else tiebreak_field_method + + if tiebreak_method != "none": + group_ids = [entries[k].tournament_participant_id for k in range(i, j)] + if tiebreak_method == "countback": + sort_keys = await _countback_sort_keys(conn, tournament_id, method, group_ids) + else: + sort_keys = await _lowest_hcp_sort_keys(conn, group_ids) + _assign_ranks_within_group(entries, list(range(i, j)), sort_keys, base_rank=rank) + else: + for k in range(i, j): + entries[k].position = None if not_started_group else (f"T{rank}" if tied else str(rank)) + entries[k].is_leader = rank == 1 and not not_started_group + entries[k].rank = None if not_started_group else rank i = j diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 821e6b5..342e967 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -62,7 +62,7 @@ _TOURNAMENT_COLUMNS = """ visibility, description, registration_deadline, registration_capacity, registration_overflow_policy, registration_requires_approval, hero_image_key, join_code, format_type, scoring_method, bbb_sweep_bonus_enabled, flag_map_visible, - cut_after_round, cut_size, cut_applied_at + cut_after_round, cut_size, cut_applied_at, tiebreak_winner_method, tiebreak_field_method """ # Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A). @@ -101,6 +101,15 @@ class Tournament(BaseModel): cut_after_round: int | None cut_size: int | None cut_applied_at: datetime | None + # Tiebreak (migrasjon 085, 2026-08-18) -- KUN meningsfullt for + # individuelle stroke play/net/stableford-turneringer, se + # individual_tournaments.py sin _attach_stroke_play_columns. Påvirker + # ALDRI Cut-grensen (ADR-087/088) -- kun sluttresultatets + # plasseringsvisning. 'none' (default) = delt plassering, uendret + # oppførsel. To UAVHENGIGE innstillinger -- vinneren og resten av + # feltet kan ha ulik metode. + tiebreak_winner_method: str + tiebreak_field_method: str def _tournament_from_row(row) -> Tournament: @@ -133,7 +142,8 @@ async def list_tournaments( t.registration_capacity, t.registration_overflow_policy, t.registration_requires_approval, t.hero_image_key, t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled, - t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at + t.flag_map_visible, t.cut_after_round, t.cut_size, t.cut_applied_at, + t.tiebreak_winner_method, t.tiebreak_field_method FROM tournament t LEFT JOIN ( SELECT tournament_id, @@ -216,6 +226,10 @@ class TournamentUpdate(BaseModel): # denne PATCH-en er kun for selve innstillingene). cut_after_round: int | None = Field(default=None, ge=1) cut_size: int | None = Field(default=None, gt=0) + # Tiebreak (migrasjon 085, 2026-08-18) -- fritt valgbar av organisator, + # se Tournament sin kommentar. + tiebreak_winner_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$") + tiebreak_field_method: str | None = Field(default=None, pattern="^(none|countback|lowest_hcp)$") # hero_image_key er BEVISST IKKE med her -- den settes kun via # upload_hero_image under, aldri som en fritekst-streng klienten sender, # siden den nå alltid kommer fra en ekte, server-konvertert opplasting. diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 53428a7..f4807be 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -117,6 +117,12 @@ type ApiTournamentInfo = { cut_after_round: number | null cut_size: number | null cut_applied_at: string | null + // Tiebreak (migrasjon 085, 2026-08-18) -- kun meningsfullt for brutto/ + // netto/stableford. To UAVHENGIGE innstillinger (vinner/felt kan ha + // ulik metode). Påvirker ALDRI Cut-grensen, kun sluttresultatets + // plasseringsvisning. "none" = delt plassering (uendret oppførsel). + tiebreak_winner_method: string + tiebreak_field_method: string } type ApiParticipant = { @@ -536,6 +542,27 @@ export function IndividualTournamentDetail({ } } + // Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme + // optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker + // ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning. + async function updateTiebreakConfig(winner: string, field: string) { + if (!tournament) return + const previous = tournament + setTournament({ ...tournament, tiebreak_winner_method: winner, tiebreak_field_method: field }) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ tiebreak_winner_method: winner, tiebreak_field_method: field }), + }) + if (!res.ok) throw new Error() + } catch { + setTournament(previous) + setError("Klarte ikke å lagre tiebreak-innstillingene. Prøv igjen.") + } + } + // MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE // automatisk ved fullspilt runde) -- idempotent på serveren, men likevel // en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra @@ -876,6 +903,7 @@ export function IndividualTournamentDetail({ onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateFlagMapVisible={updateFlagMapVisible} onUpdateCutConfig={updateCutConfig} + onUpdateTiebreakConfig={updateTiebreakConfig} onApplyCut={applyCut} onAddParticipant={addParticipant} onAddNewPlayer={addNewPlayerAndParticipant} @@ -952,6 +980,7 @@ function SetupTab({ onUpdateFlagMapVisible, onUpdateCutConfig, onApplyCut, + onUpdateTiebreakConfig, onAddParticipant, onAddNewPlayer, onImportComplete, @@ -978,6 +1007,7 @@ function SetupTab({ onUpdateFlagMapVisible: (visible: boolean) => void onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise onApplyCut: () => Promise + onUpdateTiebreakConfig: (winner: string, field: string) => Promise onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (input: NewPlayerInput) => Promise onImportComplete: () => Promise @@ -1088,7 +1118,10 @@ function SetupTab({ {tournament.scoring_method != null && ["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && ( - + <> + + + )} 6 -> 3 -> siste hull i siste runde" }, + { value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" }, +] + +function TiebreakCard({ + tournament, + onUpdateConfig, +}: { + tournament: ApiTournamentInfo + onUpdateConfig: (winner: string, field: string) => Promise +}) { + const [winner, setWinner] = useState(tournament.tiebreak_winner_method) + const [field, setField] = useState(tournament.tiebreak_field_method) + const [saving, setSaving] = useState(false) + + const dirty = winner !== tournament.tiebreak_winner_method || field !== tournament.tiebreak_field_method + + async function handleSave() { + setSaving(true) + await onUpdateConfig(winner, field) + setSaving(false) + } + + return ( +
+

Tiebreak ved likt resultat

+

+ Velges uavhengig for vinneren og resten av feltet. Påvirker kun sluttresultatets + plassering -- Cut-grensen (over) bruker alltid full delt plass, uansett denne innstillingen. +

+ +
+ + +
+ + +
+ ) +} + // Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin // ClassesCard for lagturnering-varianten (samme mønster, men uten // leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten). diff --git a/tests/test_tiebreak.py b/tests/test_tiebreak.py new file mode 100644 index 0000000..2d12b8d --- /dev/null +++ b/tests/test_tiebreak.py @@ -0,0 +1,189 @@ +""" +Tiebreak ved likt resultat i individuelle turneringer (migrasjon 085, +2026-08-18). Bruker: "Ved likt resultat ... Skal laveste hcp plasseres +foran, beste siste 18/9/6/3/siste hull plasseres foran, eller deles +plasseringene. Her kan svaret være forskjellig for vinner og resten av +feltet." Metoden er en fri organisator-innstilling, ikke fastlåst -- +to UAVHENGIGE felt (vinner/felt), og påvirker ALDRI Cut-grensen (kun +sluttresultatets plasseringsvisning). +""" + +from app.routers.individual_tournaments import ( + HoleUpdate as TournamentHoleUpdate, + apply_cut, + individual_leaderboard, + update_hole as tournament_update_hole, +) +from app.routers.tournaments import TournamentUpdate, update_tournament +from app.auth import CurrentUser + +from tests.conftest import ( + add_membership, + create_org, + create_org_hole, + create_course, + create_player, + create_tee, + create_tournament, + create_tournament_participant, + create_tournament_round, + create_tournament_round_participant, + create_user, +) + +import app.db as app_db + + +async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None: + user = CurrentUser(user_id=user_id) + for n, gross in enumerate(scores, start=1): + await tournament_update_hole( + tournament_id, round_id, rp_id, n, + TournamentHoleUpdate(gross_strokes=gross, expected_version=None), + organization_id=org_id, user=user, + ) + + +async def _setup_single_round(org_id, owner_id, method="stroke_gross"): + tournament_id = await create_tournament(org_id, name="Tiebreak-turnering") + course_id = await create_course(org_id, name="Tiebreak Links") + for n in range(1, 19): + await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n) + tee_id = await create_tee(org_id, course_id) + async with app_db.org_connection(org_id) as conn: + await conn.execute( + "UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1", + tournament_id, method, + ) + round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1) + return tournament_id, course_id, tee_id, round_id + + +async def _set_tiebreak(org_id, tournament_id, winner: str, field: str): + await update_tournament( + tournament_id, + TournamentUpdate(tiebreak_winner_method=winner, tiebreak_field_method=field), + organization_id=org_id, + ) + + +async def test_countback_resolves_winner_tie_by_better_back_nine(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round_id = await _setup_single_round(org_id, owner_id) + await _set_tiebreak(org_id, tournament_id, winner="countback", field="none") + + p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3")) + + rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id) + rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id) + rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id) + + # P1: front9 alle par (36), back9 to birdier (34) -> total 70, back9=34. + p1_scores = [4] * 9 + [3, 3] + [4] * 7 + # P2: front9 to birdier (34), back9 alle par (36) -> total 70, back9=36 (dårligere enn P1 sin back9). + p2_scores = [3, 3] + [4] * 7 + [4] * 9 + # P3: klart bak, ingen uavgjort. + p3_scores = [5] * 18 + + await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, p1_scores) + await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, p2_scores) + await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, p3_scores) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + by_id = {e.tournament_participant_id: e for e in entries} + + assert by_id[p1].position == "1" # bedre back9 -> sole leder, IKKE "T1" + assert by_id[p1].is_leader is True + assert by_id[p2].position == "2" + assert by_id[p2].is_leader is False + assert by_id[p3].position == "3" + + +async def test_lowest_hcp_resolves_field_tie_but_not_winner(pool): + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round_id = await _setup_single_round(org_id, owner_id) + # Vinner-tiebreak IKKE satt (none) -- kun felt-tiebreak skal virke. + await _set_tiebreak(org_id, tournament_id, winner="none", field="lowest_hcp") + + leader = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="Leader")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + p3 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P3")) + + async with app_db.org_connection(org_id) as conn: + await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 8.0 WHERE id = $1", p2) + await conn.execute("UPDATE tournament_participant SET handicap_index_snapshot = 20.0 WHERE id = $1", p3) + + rp_leader = await create_tournament_round_participant(org_id, round_id, leader, tee_id) + rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id) + rp3 = await create_tournament_round_participant(org_id, round_id, p3, tee_id) + + await _score_holes(tournament_id, round_id, rp_leader, org_id, owner_id, [3] * 18) # klar leder, -18 + await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) # uavgjort med p3, E + await _score_holes(tournament_id, round_id, rp3, org_id, owner_id, [4] * 18) # uavgjort med p2, E + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + by_id = {e.tournament_participant_id: e for e in entries} + + assert by_id[leader].position == "1" + assert by_id[leader].is_leader is True + assert by_id[p2].position == "2" # lavere HCP (8.0 < 20.0) -> foran + assert by_id[p3].position == "3" + + +async def test_tiebreak_method_none_keeps_shared_position(pool): + """Standard/uendret oppførsel -- ingen tiebreak konfigurert i det hele + tatt (default 'none' for begge felt, ingen regresjon).""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round_id = await _setup_single_round(org_id, owner_id) + + p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + rp1 = await create_tournament_round_participant(org_id, round_id, p1, tee_id) + rp2 = await create_tournament_round_participant(org_id, round_id, p2, tee_id) + + await _score_holes(tournament_id, round_id, rp1, org_id, owner_id, [4] * 18) + await _score_holes(tournament_id, round_id, rp2, org_id, owner_id, [4] * 18) + + entries = await individual_leaderboard(tournament_id, organization_id=org_id) + by_id = {e.tournament_participant_id: e for e in entries} + assert by_id[p1].position == "T1" + assert by_id[p2].position == "T1" + assert by_id[p1].is_leader is True + assert by_id[p2].is_leader is True + + +async def test_tiebreak_never_affects_cut_boundary(pool): + """Bruker bekreftet eksplisitt: tiebreak påvirker KUN sluttresultatet, + ALDRI selve cut-grensen -- full delt plass på grensen uansett + tiebreak-innstilling.""" + org_id = await create_org() + owner_id = await create_user() + await add_membership(org_id, owner_id, role="owner") + tournament_id, course_id, tee_id, round1_id = await _setup_single_round(org_id, owner_id) + round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2) + # Countback konfigurert -- skal likevel IKKE påvirke cutten. + await _set_tiebreak(org_id, tournament_id, winner="countback", field="countback") + await update_tournament( + tournament_id, TournamentUpdate(cut_after_round=1, cut_size=1), organization_id=org_id, + ) + + p1 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P1")) + p2 = await create_tournament_participant(org_id, tournament_id, await create_player(org_id, display_name="P2")) + rp1 = await create_tournament_round_participant(org_id, round1_id, p1, tee_id) + rp2 = await create_tournament_round_participant(org_id, round1_id, p2, tee_id) + # Identisk totalt OG identisk back9 -- ekte fullt uavgjort, selv med countback slått på. + await _score_holes(tournament_id, round1_id, rp1, org_id, owner_id, [4] * 18) + await _score_holes(tournament_id, round1_id, rp2, org_id, owner_id, [4] * 18) + + result = await apply_cut(tournament_id, organization_id=org_id) + assert result.cut_size == 1 + assert result.survivors == 2 # begge slipper gjennom -- delt 1.-plass, IKKE brutt av countback + assert result.cut_count == 0