Tiebreak ved likt resultat: countback eller laveste HCP (ADR-089)
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.
This commit is contained in:
parent
d57f4e5478
commit
300cb8aeaf
7 changed files with 634 additions and 10 deletions
36
085_tournament_tiebreak.sql
Normal file
36
085_tournament_tiebreak.sql
Normal file
|
|
@ -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'));
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
29
CHANGELOG.md
29
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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<void>
|
||||
onApplyCut: () => Promise<void>
|
||||
onUpdateTiebreakConfig: (winner: string, field: string) => Promise<void>
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||
onImportComplete: () => Promise<void>
|
||||
|
|
@ -1088,7 +1118,10 @@ function SetupTab({
|
|||
|
||||
{tournament.scoring_method != null &&
|
||||
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
|
||||
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
|
||||
<>
|
||||
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
|
||||
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ClassesCard
|
||||
|
|
@ -1219,6 +1252,95 @@ function CutCard({
|
|||
)
|
||||
}
|
||||
|
||||
// Tiebreak (migrasjon 085, 2026-08-18) -- "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." Bruker: metoden skal defineres av
|
||||
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
|
||||
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
|
||||
// CutCard rett over.
|
||||
const TIEBREAK_OPTIONS: { value: string; label: string; hint: string }[] = [
|
||||
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1», «T5» osv." },
|
||||
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 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<void>
|
||||
}) {
|
||||
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 (
|
||||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||
<h2 className="text-base font-bold text-foreground">Tiebreak ved likt resultat</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Velges uavhengig for vinneren og resten av feltet. Påvirker kun sluttresultatets
|
||||
plassering -- Cut-grensen (over) bruker alltid full delt plass, uansett denne innstillingen.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<label className="flex flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-bold text-foreground">Ved likt resultat for vinneren</span>
|
||||
<select
|
||||
value={winner}
|
||||
onChange={(e) => setWinner(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{TIEBREAK_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TIEBREAK_OPTIONS.find((o) => o.value === winner)?.hint}
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1.5">
|
||||
<span className="text-sm font-bold text-foreground">Ved likt resultat i resten av feltet</span>
|
||||
<select
|
||||
value={field}
|
||||
onChange={(e) => setField(e.target.value)}
|
||||
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{TIEBREAK_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TIEBREAK_OPTIONS.find((o) => o.value === field)?.hint}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!dirty || saving}
|
||||
className="inline-flex h-11 w-fit items-center justify-center gap-2 self-end rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Lagrer..." : "Lagre innstilling"}
|
||||
</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
|
|
|||
189
tests/test_tiebreak.py
Normal file
189
tests/test_tiebreak.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue