Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble. Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt. Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
This commit is contained in:
parent
997f3c40ed
commit
83c4749516
12 changed files with 2401 additions and 116 deletions
68
031_round_play_formats.sql
Normal file
68
031_round_play_formats.sql
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — ekte spillformer for frittstående runder (migrasjon 031,
|
||||
-- ADR-039)
|
||||
-- =====================================================================
|
||||
-- Utvider round.play_format (ADR-038) fra en ren HCP-eksklusjons-etikett
|
||||
-- til å faktisk styre scoringsmodell + scorekort: match/fourball/
|
||||
-- foursome/greensome/scramble trenger et "sider"-konsept (Beslutning A),
|
||||
-- og delt-ball-formatene (foursome/greensome/scramble) trenger én
|
||||
-- kombinert score PER SIDE per hull i stedet for per spiller
|
||||
-- (Beslutning C) -- gjenbruker samme nullable-nøkkel-mønster som
|
||||
-- org-scopet `hole_score` (migrasjon 001) i stedet for en helt ny tabell.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4'));
|
||||
|
||||
-- Skins (Beslutning D) -- kun meningsfullt når play_format='skins',
|
||||
-- håndheves i app-laget (samme "kun meningsfullt for..."-mønster som
|
||||
-- resten av round -- ikke en tabellbasert CHECK på tvers av kolonner).
|
||||
ALTER TABLE round ADD COLUMN skins_scoring text CHECK (skins_scoring IN ('net', 'gross'));
|
||||
ALTER TABLE round ADD COLUMN skins_tie_handling text CHECK (skins_tie_handling IN ('carry', 'split'));
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Sider (Beslutning A) -- nøyaktig to per runde for match/fourball/
|
||||
-- foursome/greensome/scramble, håndhevet i app-laget (samme mønster som
|
||||
-- ADR-011s to-lags-grense for org-turneringer), ikke en DB-constraint.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE round_side (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
|
||||
label text
|
||||
);
|
||||
|
||||
CREATE INDEX ON round_side (round_id);
|
||||
|
||||
ALTER TABLE round_participant ADD COLUMN round_side_id uuid REFERENCES round_side(id) ON DELETE SET NULL;
|
||||
|
||||
-- Playing Handicap (relativ til motparten via match_play_strokes) --
|
||||
-- course_handicap_snapshot (eksisterende) er ABSOLUTT og uendret i
|
||||
-- betydning; playing_handicap er den format-avledede enheten, samme
|
||||
-- to-kolonne-mønster som org-scopet match_participant.
|
||||
ALTER TABLE round_participant ADD COLUMN playing_handicap smallint;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- round_hole: nøyaktig ÉN av round_participant_id/round_side_id
|
||||
-- (Beslutning C) -- delt-ball-formater (foursome/greensome/scramble)
|
||||
-- lagrer én rad PER SIDE per hull, ikke per spiller. Individuell-ball-
|
||||
-- formater (slagspill/match/fourball/skins) er uendret -- én rad per
|
||||
-- DELTAKER per hull, som i dag.
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE round_hole ALTER COLUMN round_participant_id DROP NOT NULL;
|
||||
ALTER TABLE round_hole ADD COLUMN round_side_id uuid REFERENCES round_side(id) ON DELETE CASCADE;
|
||||
ALTER TABLE round_hole ADD CONSTRAINT round_hole_owner_xor CHECK (
|
||||
((round_participant_id IS NOT NULL)::int + (round_side_id IS NOT NULL)::int) = 1
|
||||
);
|
||||
|
||||
ALTER TABLE round_hole DROP CONSTRAINT round_hole_round_participant_id_hole_number_key;
|
||||
CREATE UNIQUE INDEX round_hole_participant_unique
|
||||
ON round_hole (round_participant_id, hole_number) WHERE round_participant_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX round_hole_side_unique
|
||||
ON round_hole (round_side_id, hole_number) WHERE round_side_id IS NOT NULL;
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON round_side TO teecup_app;
|
||||
|
|
@ -3482,6 +3482,128 @@ er etablert), ikke behandlet som en feil.
|
|||
|
||||
---
|
||||
|
||||
## ADR-039: Ekte spillformer for frittstående runder (match/skins/par-lag)
|
||||
|
||||
**Kontekst:** reist 2026-07-28, rett etter ADR-038: "Når en singlerunde
|
||||
settes opp: Er dette en slagspillsrunde, en match mellom to spillere,
|
||||
skins, eller en par- eller lag-konkurranse. Avhengig av svaret så må hcp
|
||||
beregnes forskjellig, og også scorekortet vil se annerledes ut." Viktig
|
||||
presisering: `round.play_format` (ADR-038) styrer i dag KUN et
|
||||
HCP-eksklusjonsforslag — ikke selve scorings- eller scorekort-modellen.
|
||||
Dette utvider `play_format` til å faktisk STYRE begge deler.
|
||||
|
||||
Fire load-bærende beslutninger avklart eksplisitt med bruker
|
||||
(AskUserQuestion, to runder) før denne ADR-en ble skrevet.
|
||||
|
||||
### Beslutning A — To sider per runde, gjenbruker ADR-011s antakelse
|
||||
|
||||
Match/fourball/foursome/greensome/scramble trenger et «hvem spiller
|
||||
mot hvem»-konsept `round_participant` ikke har i dag (en flat liste
|
||||
uten gruppering). Løst med nøyaktig TO SIDER per runde (ny
|
||||
`round_side`-tabell), håndhevet i app-laget — samme mønster som
|
||||
ADR-011s to-lags-grense for organisasjons-turneringer, ikke en
|
||||
DB-constraint. En side er 1 spiller (match) eller 2+ (fourball/
|
||||
foursome/greensome/scramble_2/scramble_4). `round_participant.
|
||||
round_side_id` (nullable — kun satt for to-sidede formater) knytter
|
||||
deltakeren til sin side.
|
||||
|
||||
### Beslutning B — Alle fire par-/lag-underformater fra start, samme motor som turneringer
|
||||
|
||||
Bruker valgte bredt omfang: fourball, foursome, greensome OG begge
|
||||
scramble-variantene (`scramble_2`/`scramble_4`) — ikke bare fourball
|
||||
(som alene ville passet uendret inn i dagens skjema).
|
||||
|
||||
**Gjenbruker den EKSISTERENDE, testede match-play-motoren fullstendig
|
||||
uendret** (`handicap_engine.py` sin `Format`-enum,
|
||||
`AllowanceStrategy`-familien, `match_play_strokes`,
|
||||
`allocate_over_played_holes`, `compute_match_state`, `HoleResult`) —
|
||||
ingen ny motorkode for disse fire formatene. Selve API-laget
|
||||
(`app/routers/rounds.py`) porterer det allerede bevisste,
|
||||
produksjonskjørte mønsteret fra `app/handicap.py`/`app/routers/
|
||||
scoring.py` (`compute_and_store_side_handicaps` →
|
||||
`relative_strokes_for_match` → `_side_net`/`_compute_hole_results` →
|
||||
`recompute_and_cache_match_state`), tilpasset `round`/`round_side` i
|
||||
stedet for `match`/`team_side`. Samme `_SIDE_IS_UNIT`/`FORMAT_UNIT_SIZE`-
|
||||
tabeller (hvor mange spillere som kreves per side før handicap kan
|
||||
beregnes) gjenbrukes identisk.
|
||||
|
||||
### Beslutning C — Delt-ball-formater: `round_hole` gjenbruker `hole_score`s nullable-nøkkel-mønster
|
||||
|
||||
Foursome/greensome/scramble har ÉN kombinert score per SIDE per hull,
|
||||
ikke én per spiller — akkurat samme situasjon som org-scopet
|
||||
`hole_score` allerede løser (`match_participant_id` NULLABLE, delt-ball-
|
||||
rader identifisert av `team_side` alene, to partielle unike indekser).
|
||||
`round_hole.round_participant_id` gjøres NULLABLE, ny
|
||||
`round_hole.round_side_id` lagt til, med samme
|
||||
`(participant XOR side)`-CHECK og to partielle unike indekser som
|
||||
originalen. For fourball/match (individuell ball) forblir `round_hole`
|
||||
uendret, én rad per DELTAKER per hull, akkurat som i dag.
|
||||
|
||||
**Konsekvens for statistikk (ADR-033 Beslutning B):** de detaljerte
|
||||
per-hull-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
|
||||
ingen entydig mening for en DELT ball (hvem sitt slag var det?) — for
|
||||
delt-ball-formater lagres derfor KUN selve slagtallet på siderraden,
|
||||
ingen av de andre detalj-feltene (samme "et fast sett felt, ikke fri
|
||||
logg"-prinsipp, bare at feltsettet er tynnere for denne raden).
|
||||
|
||||
### Beslutning D — Skins: konfigurerbar netto/brutto OG rullerer/deles, ny motorfunksjon
|
||||
|
||||
Ingen eksisterende motorstøtte for skins i det hele tatt (ikke et
|
||||
turnering-format). Bruker valgte at DEN SOM SETTER OPP runden skal
|
||||
kunne velge BEGGE akser uavhengig — ikke ett fastlåst standardvalg:
|
||||
- `round.skins_scoring`: `'net'` (HCP-slag trekkes fra, WHS-vanlig) |
|
||||
`'gross'`.
|
||||
- `round.skins_tie_handling`: `'carry'` (uavgjort hulls "skin" ruller
|
||||
til neste hull, vinneren der tar hele potten) | `'split'` (uavgjort
|
||||
hulls skin deles likt mellom de tied spillerne i stedet).
|
||||
|
||||
Ny, ren, testbar motorfunksjon `compute_skins(scores_by_hole,
|
||||
tie_handling)` i `handicap_engine.py` — tar allerede netto-ELLER-brutto-
|
||||
avgjorte per-hull-scorer (kalleren bestemmer hvilket FØR kall, motoren
|
||||
vet ikke selv om input er netto eller brutto), returnerer
|
||||
`{participant_id: antall skins vunnet}` (float, siden 'split' kan gi
|
||||
brøkdeler). Ingen sider trengs for skins — rent individuelt, som
|
||||
slagspill.
|
||||
|
||||
### Beslutning E — HCP-telling per format
|
||||
|
||||
- **Slagspill, fourball, skins** (individuell ball — hver spiller har
|
||||
en FULL, ekte brutto-score per hull): teller normalt mot faktisk HCP,
|
||||
samme pipeline som i dag (Score Differential fra AGS). Bekreftet
|
||||
eksplisitt av bruker for skins — "krever fullt utspilte hull (ingen
|
||||
konsedering som i match) -- samme grunnlagsproblem som gjorde match
|
||||
upålitelig gjelder ikke her."
|
||||
- **Match** (uendret fra ADR-038): fortsatt "spør, anbefal
|
||||
ekskludering" — Rule 3.3-problemet (konsederte hull) gjelder fortsatt
|
||||
uendret.
|
||||
- **Foursome/greensome/scramble** (delt ball — INGEN individuell score
|
||||
finnes å telle): `counts_for_handicap` forblir usann for disse
|
||||
deltakerne i disse rundene, alltid, uansett antall spilte hull.
|
||||
Bekreftet eksplisitt av bruker som riktig løsning — matcher for
|
||||
øvrig hvordan WHS i praksis også behandler delt-ball-formater (ikke
|
||||
indeks-byggende på samme måte som individuell/fourball-spill).
|
||||
|
||||
### Beslutning F — Scorekort-presentasjon (frontend, ikke bygget i denne runden)
|
||||
|
||||
Notert for senere: match/fourball/foursome/greensome/scramble trenger
|
||||
en løpende matchstatus-visning (gjenbruk av `describe()`-språket fra
|
||||
`MatchState`, samme som `session-scorecard.tsx` allerede viser for
|
||||
turnering-matcher) i stedet for rå slagsummer. Skins trenger en
|
||||
skins-tavle (skins vunnet per spiller + hvilke hull som fortsatt er
|
||||
"i potten"). Presist UI-design ikke gjort i denne runden — kun
|
||||
backend/motor/skjema.
|
||||
|
||||
### Konsekvens — hva som bygges i DENNE runden vs. senere
|
||||
|
||||
Bruker valgte å gå rett til migrasjon + motor (ikke bare dokumentere).
|
||||
Bygget: skjema (migrasjon 031), `compute_skins`-motorfunksjon + tester,
|
||||
og backend-API for sider/delt-ball-scoring/format-resultat. Frontend
|
||||
(nye skjermer for sideoppsett og de nye scorekort-presentasjonene,
|
||||
Beslutning F) er en egen, senere runde — samme lagdeling som ADR-038
|
||||
(backend/motor FØR frontend).
|
||||
|
||||
---
|
||||
|
||||
## Åpne spørsmål (ikke besluttet ennå)
|
||||
|
||||
Disse må avklares før eller under de relevante fasene:
|
||||
|
|
@ -3513,6 +3635,16 @@ Disse må avklares før eller under de relevante fasene:
|
|||
(sesong-sammenlagt på tvers av flere turneringer) — bekreftet som et
|
||||
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
|
||||
skrevet — ADR-037 er ren struktur-beslutning.
|
||||
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
|
||||
par-lag-konkurranse, reist 2026-07-28) — **AVKLART OG BACKEND BYGGET
|
||||
SAMME DAG, se ADR-039.** `round.play_format` styrer nå faktisk både
|
||||
HCP-beregning (via en portert versjon av turnering-motoren) og et
|
||||
nytt `format-result`-lese-endepunkt for scorekort-presentasjon.
|
||||
Fortsatt IKKE utrullet mot ekte systemer, og frontend (sideoppsett/
|
||||
skins-konfig/matchstatus-visning) fortsatt ikke bygget — se
|
||||
FEATURE_BACKLOG.md. "Flere flighter i én frittstående runde" (egen
|
||||
seksjon i FEATURE_BACKLOG.md) forblir et adskilt, ikke-relatert
|
||||
spørsmål (grupperer RUNDER, ikke deltakere innad i én runde).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
229
CLAUDE.md
229
CLAUDE.md
|
|
@ -4071,6 +4071,226 @@ Ferdig og verifisert:
|
|||
ADR-037-arbeid), ingen egen "aging"-bakgrunnsjobb utover at spørringen
|
||||
alltid henter kun de 20 nyeste differensialene.
|
||||
|
||||
- **Ekte spillformer for frittstående runder (match/skins/fourball/
|
||||
foursome/greensome/scramble), BACKEND BYGGET OG SCRATCH-VERIFISERT,
|
||||
IKKE ENNÅ RULLET UT MOT EKTE SYSTEMER (2026-07-28, ADR-039):** reist av
|
||||
brukeren rett etter ADR-038: "Er dette en slagspillsrunde, en match
|
||||
mellom to spillere, skins, eller en par- eller lag-konkurranse.
|
||||
Avhengig av svaret så må hcp beregnes forskjellig, og også scorekortet
|
||||
vil se annerledes ut." Fire load-bærende avklaringer (AskUserQuestion,
|
||||
to runder) FØR bygging — bruker valgte det bredeste omfanget i alle
|
||||
runder: to sider (Beslutning A), ALLE fire par-/lag-underformater fra
|
||||
start inkl. de to delt-ball-krevende (Beslutning B), skins med BEGGE
|
||||
akser konfigurerbare av oppsetteren (netto/brutto OG rullerer/deles,
|
||||
Beslutning D), og gå rett til migrasjon+motor samme økt (ikke bare
|
||||
dokumentere).
|
||||
**Kjerneinnsikt som gjorde dette trygt å bygge fort:** hele match-play-
|
||||
motoren (`handicap_engine.py` sin `Format`/`AllowanceStrategy`-familie/
|
||||
`match_play_strokes`/`compute_match_state`) og hele "delt-ball vs.
|
||||
individuell"-mønsteret (`hole_score.match_participant_id` NULLABLE,
|
||||
delt-ball-rader identifisert av `team_side` alene) fantes ALLEREDE,
|
||||
bygget og produksjonskjørt for org-scopede turnering-matcher. Denne
|
||||
runden PORTERER dette mønsteret til frittstående runder (nye
|
||||
`round_side`/`round_participant.round_side_id`/`round_participant.
|
||||
playing_handicap`/`round_hole.round_side_id`) i stedet for å finne opp
|
||||
noe nytt — kun skins (ingen turnering-motstykke) fikk EKTE ny
|
||||
motorkode (`compute_skins`, 7 nye tester, 50/50 i `test_handicap_
|
||||
engine.py`). `app/handicap.py` sin `_SIDE_IS_UNIT` omdøpt til
|
||||
`SIDE_IS_UNIT` (gjort delt for gjenbruk, samme "fjern understrek når
|
||||
et andre bruksted dukker opp"-mønster som tidligere runder).
|
||||
**Skjema, migrasjon `031_round_play_formats.sql`:** `round.play_format`
|
||||
utvidet til 8 verdier, nye `round.skins_scoring`/`skins_tie_handling`,
|
||||
ny `round_side`-tabell (nøyaktig to per runde, håndhevet i app-laget
|
||||
som ADR-011s to-lags-grense), `round_participant.round_side_id`/
|
||||
`playing_handicap` (sistnevnte ALDRI det samme som det eksisterende
|
||||
`course_handicap_snapshot` — den absolutte WHS-verdien rørt av INGEN
|
||||
av denne rundens kode), `round_hole.round_participant_id` gjort
|
||||
NULLABLE + ny `round_hole.round_side_id` + XOR-CHECK + to partielle
|
||||
unike indekser (samme mønster som org-scopet `hole_score`, migrasjon
|
||||
001).
|
||||
**Reelt, bekreftet funn UNDER selve designet (ikke antatt), avklart
|
||||
eksplisitt med bruker FØR bygging:** foursome/greensome/scramble har
|
||||
ÉN kombinert score per SIDE per hull -- INGEN individuell score
|
||||
finnes i det hele tatt å bygge en Score Differential fra. Løst
|
||||
(Beslutning E, bekreftet av bruker): disse deltakerne får
|
||||
`counts_for_handicap` ALDRI sann for disse rundene -- og viste seg,
|
||||
presist verifisert i scratch, å følge HELT AUTOMATISK av den
|
||||
eksisterende `complete_round`-logikken uten noen kodeendring i det
|
||||
hele tatt (en delt-ball-deltaker har null individuelle `round_hole`-
|
||||
rader, så `played_count` blir alltid 0, som `round_counts_for_
|
||||
handicap` allerede tolker som "teller ikke" for både 9- og
|
||||
18-hulls-intensjon).
|
||||
**API (`app/routers/rounds.py`):** `POST/DELETE .../sides` (eier-only,
|
||||
maks to, avvist for slagspill/skins), `ParticipantCreate`/
|
||||
`ParticipantUpdate` fikk `round_side_id` (eier-only reassignment,
|
||||
validerer siden hører til samme runde), `_recompute_side_handicaps`
|
||||
(porterer `compute_and_store_side_handicaps` — venter på at siden når
|
||||
forventet spillerantall FØR den skriver noe, samme
|
||||
"ikke komplett ennå = ikke skriv"-filosofi som originalen),
|
||||
`_relative_strokes_for_round` (porterer `relative_strokes_for_match`),
|
||||
nye `GET/PATCH .../sides/{id}/holes/{n}` (delt-ball-scoring, kun
|
||||
slagtall — ingen av de andre detalj-feltene gir mening for en delt
|
||||
ball), og et nytt lese-endepunkt `GET .../format-result` som regner
|
||||
løpende matchstatus (gjenbruker `compute_match_state`/`HoleResult`
|
||||
uendret) for de to-sidede formatene, eller en skins-tavle
|
||||
(`compute_skins`) for skins — aldri lagret, alltid avledet ved lesing.
|
||||
**To reelle bugs funnet OG fikset UNDER scratch-testing, ingen nådde
|
||||
produksjon:** (1) side-tildelings-recompute kjørte FØR responsen ble
|
||||
hentet i stedet for ETTER — testen fanget dette presist (forventet
|
||||
`playing_handicap` i responsen, fikk `null` fra FØR omregningen); (2)
|
||||
individuell-ball-gren i format-result-spørringen nøkkel-forvekslet
|
||||
"enhet" (satte `round_side_id` som nøkkel i stedet for
|
||||
`round_participant_id`, mens `side_net()`-oppslaget forventet
|
||||
deltaker-id) — ga et tomt `hole_results` til tross for gyldige
|
||||
registrerte scorer, fanget da `match_holes_played` kom ut som 0 i
|
||||
stedet for det forventede 3.
|
||||
**Scratch-verifisert grundig, 96/96 sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
|
||||
container via ekte HTTP, samme mønster som hele prosjektet): en
|
||||
hånd-utregnet 3-hulls singles-match (hcp 5 vs. 10, riktig slagmottak
|
||||
på de fem vanskeligste hullene) ga eksakt `lead=0`/"AS"/riktig
|
||||
hole-for-hole-mønster; fourball bekreftet INDIVIDUELL (ikke kombinert)
|
||||
90 %-beregning per spiller, korrekt "ikke komplett ennå" før andre
|
||||
spiller på siden var tildelt; foursome bekreftet 18 round_hole-rader
|
||||
opprettet på SIDEN ved side-opprettelse (før noen deltaker lagt til),
|
||||
korrekt kombinert 50 %-Playing-Handicap først når begge var tildelt,
|
||||
ekte delt-ball-scoring via det nye sub-endepunktet, OG at
|
||||
`counts_for_handicap` ble `False` for begge etter fullføring; skins
|
||||
(netto+carry) ga eksakt `{sk3: 2.0}` for et 2-hulls scenario med et
|
||||
bevisst konstruert uavgjort-så-carry-så-outright-vinn-mønster, OG
|
||||
bekreftet skins teller NORMALT mot faktisk HCP etter full fullføring
|
||||
(ulikt match). `test_isolation.sql` 12/12 uendret (additiv migrasjon).
|
||||
Alle 50 handicap_engine-tester (43 eksisterende + 7 nye skins) grønne.
|
||||
**IKKE bygget i denne runden, bevisst utsatt (Beslutning F):**
|
||||
frontend — ingen skjerm for å opprette sider, tildele deltakere,
|
||||
konfigurere skins, eller vise løpende matchstatus/skins-tavle. Backend
|
||||
er fullt funksjonelt og testet, men ubrukelig fra selve appen inntil
|
||||
frontend bygges i en egen, senere runde (samme lagdelings-mønster som
|
||||
ADR-038: motor/skjema/API FØR frontend).
|
||||
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
|
||||
eksplisitt: migrasjon 031 kjørt mot ekte `teecup_db` (nye kolonner/
|
||||
`round_side`-tabell bekreftet, `test_isolation.sql` fortsatt 12/12),
|
||||
deretter `docker compose up -d --build teecup_api`. Ren boot,
|
||||
`/health`/`/dashboard` → 200, ny rute bekreftet nåbar (anonymt
|
||||
`POST .../sides` → 401, ikke en rå 404), `teeoff.no` upåvirket.
|
||||
|
||||
- **Oppfølging samme dag: manglende minimums-spiller-håndhevelse fanget
|
||||
og fikset, BYGGET OG SCRATCH-VERIFISERT, RULLET UT LIVE (2026-07-28):**
|
||||
brukeren påpekte presist et hull ADR-039 selv ikke fanget opp: "Er det
|
||||
match-spill, skins eller lagspill så MÅ det jo være flere spillere.
|
||||
Dette fanges ikke opp." Riktig — ingenting hindret å fullføre en
|
||||
"match" med kun eieren, eller la flere spillere enn formatet tillater
|
||||
havne på samme side. Bekreftet med bruker (AskUserQuestion): skins
|
||||
krever minst 3 spillere (2 gjør skins i praksis identisk med en vanlig
|
||||
match — 3+ er der en oppsamlet, uavgjort pott faktisk gir mening).
|
||||
**Bygget, ren Python-logikk, INGEN migrasjon:** ny
|
||||
`_format_setup_status()` — slagspill alltid klar; skins krever minst
|
||||
3 deltakere; to-sidede formater (match/fourball/foursome/greensome/
|
||||
scramble) krever NØYAKTIG to sider, INGEN uassignerte deltakere, og
|
||||
hver side nøyaktig riktig spillerantall for formatet
|
||||
(`_SIDE_PLAYER_COUNT`, allerede definert). Ny `setup_complete`/
|
||||
`setup_message` på `RoundOut` (alltid synlig, uansett format/status).
|
||||
`complete_round` avviser nå (409 `SETUP_INCOMPLETE`) hvis oppsettet
|
||||
ikke er komplett — FØR noe regnes ut. Ny `_check_side_capacity()`
|
||||
avviser (409 `SIDE_FULL`) proaktivt ved SELVE tildelingen (både
|
||||
`POST .../participants` med `round_side_id` og `PATCH .../
|
||||
participants/{id}`), i stedet for å først oppdage overtallet ved
|
||||
fullføring — med eksplisitt unntak for en no-op-reassignment til
|
||||
samme side (en deltaker teller ikke seg selv ut av plassen sin egen
|
||||
side har).
|
||||
**Scratch-verifisert grundig, 122/122 sjekker** (samme isolerte
|
||||
scratch-oppsett som resten av runden — full regresjon av alle
|
||||
tidligere 96 sjekker PLUSS 26 nye): 3. spiller avvist på en full
|
||||
match-/foursome-side (409 SIDE_FULL), `setup_complete` korrekt False
|
||||
ved kun 1 av 2 sider / uassignerte deltakere / for få skins-spillere,
|
||||
fullføring korrekt avvist (409 SETUP_INCOMPLETE) i alle disse
|
||||
tilstandene, og korrekt True (+ vellykket fullføring) først når
|
||||
oppsettet faktisk er komplett for formatet. `test_isolation.sql`
|
||||
uendret (ingen skjemaendring).
|
||||
**Rullet ut live 2026-07-28**, ingen migrasjon, kun
|
||||
`docker compose up -d --build teecup_api`. Ren boot, `/health`/
|
||||
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
- **Frontend for ADR-039 (sider/skins/delt-ball-scoring) BYGGET, BROWSER-
|
||||
VERIFISERT OG LIVE (2026-07-28), samme dag:** ingen backend-endring i
|
||||
denne runden (alt allerede live) — ren frontend-jobb, testet reelt i
|
||||
nettleser mot en isolert scratch-backend (Chrome DevTools), ikke bare
|
||||
typesjekk.
|
||||
**`new-round.tsx`:** spilleform-velgeren utvidet fra to (Slagspill/
|
||||
Match) til alle åtte format (Skins/Fourball/Foursome/Greensome/
|
||||
Scramble 2/4 lagt til), med en ny skins-konfigurasjonsseksjon (netto/
|
||||
brutto, rullerer/deles) som kun vises for `play_format="skins"` og et
|
||||
forklarende "du setter opp sidene inni runden etterpå"-notat for de
|
||||
to-sidede formatene (ADR-039 Beslutning A -- sider kan ikke opprettes
|
||||
før deltakerne finnes).
|
||||
**`round-detail.tsx` (hoveddelen):** ny `SidesPanel` (manage-fanen) --
|
||||
opprett/slett de to sidene, tildel/fjern deltakere (kompakte
|
||||
"→ Side"-hurtigknapper, deaktivert når siden er full), viser
|
||||
`playing_handicap` per side. Ny `FormatResultPanel` -- henter
|
||||
`GET .../format-result`, viser løpende matchstatus (oversetter
|
||||
motorens bokstavelige "(A)"/"(B)" til faktiske side-navn via
|
||||
`round.sides[0]/[1]`, samme sorteringsrekkefølge som backend) eller en
|
||||
skins-tavle (sortert synkende). `setup_complete`/`setup_message`
|
||||
gater nå "Fullfør runde"-knappen klientside også (server er fortsatt
|
||||
autoritativ). For delt-ball-formatene (foursome/greensome/scramble):
|
||||
ny `SideScorecardGrid` (rader = sider, ikke spillere) + ny, forenklet
|
||||
`SideScoreWizard` (kun slagtall, ingen putt/detalj-steg) mot de
|
||||
eksisterende `GET/PATCH .../sides/{id}/holes/{n}`-endepunktene.
|
||||
**To reelle stale-state-bugs funnet UNDER selve browserverifiseringen
|
||||
(ikke i kodegjennomgang), begge fikset før utrulling:**
|
||||
1. `SidesPanel` sin `assign()` oppdaterte kun deltaker-listen lokalt
|
||||
(via `onPatchParticipant`) -- `setup_complete`/`setup_message`
|
||||
(server-beregnet) ble stående utdatert etter en vellykket
|
||||
side-tildeling ("ikke tildelt en side" fortsatte å vises til tross
|
||||
for at begge var tildelt). Fikset: `assign()` kaller nå
|
||||
`onSidesChanged()` (full runde-refetch) etter en vellykket PATCH.
|
||||
2. `FormatResultPanel` sin refetch var kun koblet til hull-registrering
|
||||
og WebSocket-signaler, ikke til side-/deltaker-tildeling --
|
||||
matchstatus ble stående på "venter..." selv etter at oppsettet var
|
||||
komplett og "Fullfør runde" allerede var aktivert. Fikset ved å
|
||||
bumpe `formatResultRefreshTick` ved HVER vellykket `loadRound()`
|
||||
(enklere og mer robust enn å spore hvert enkelt kallsted som kan
|
||||
påvirke handicap-beregningen).
|
||||
**Verifisert grundig i en isolert scratch-nettleserøkt** (fersk
|
||||
`teecup_scratch`-database, isolert scratch-MinIO, engangs API-
|
||||
container, ekte `next dev` mot scratch-backend, Chrome DevTools MCP --
|
||||
ekte innlogging via magic-link, ekte profil-fullføring): tre komplette
|
||||
runder bygget og spilt gjennom UI-et alene, ende-til-ende:
|
||||
- **Match:** opprettet med eksklusjons-avkrysning forhåndshuket,
|
||||
opprettet to sider, tildelte eier+gjest, bekreftet `playing_handicap`
|
||||
(60/20) vist riktig, scoret hull 1 (4 mot 6) via `ScoringWizard`
|
||||
(ubrørt komponent), bekreftet `FormatResultPanel` viste "1 UP
|
||||
(Erol)" + riktig fargede hull-merker -- kryssjekket med
|
||||
`aria-label`-attributtet direkte via `evaluate_script` for å bekrefte
|
||||
semantisk korrekt side-navn bak den rå A/B-bokstaven.
|
||||
- **Skins:** konfigurasjons-UI-et (netto/brutto, rullerer/deles) bekreftet
|
||||
visuelt, opprettet med kun 1 spiller (satte-message "krever minst 3"),
|
||||
la til to gjester til (meldingen forsvant idet den tredje ble lagt til,
|
||||
"Fullfør runde" aktivert), scoret hull 1 for alle tre (via direkte
|
||||
API-kall for hastighet, samme kontrakt som UI-et bruker), bekreftet
|
||||
skins-tavlen -- **hånd-regnet og kryssjekket eksakt**: netto 1/4/3 for
|
||||
de tre spillerne (course handicap 60/12/6, alle mottar 1 slag på
|
||||
hull 1 unntatt eieren som mottar 4) ga korrekt "1 skin" til laveste
|
||||
netto.
|
||||
- **Foursome:** bekreftet "Opprett begge sidene..."-meldingen i Score-
|
||||
fanen FØR sidene fantes (ingen krasj), opprettet to sider, la til tre
|
||||
gjester, scoret hull 1 via `SideScorecardGrid`/`SideScoreWizard`
|
||||
(5 mot 5 -- observerte LIVE at gridet oppdaterte seg bak selve
|
||||
veiviseren), fant OG fikset de to stale-state-bugene over midt i
|
||||
denne runden (glemte først å tildele spillerne til sider -- avdekket
|
||||
nettopp fordi UI-et da IKKE viste feil tilstand, men en ekte utdatert
|
||||
en), bekreftet til slutt `playing_handicap` kombinert riktig per side
|
||||
(38/38 og 17/17) og at `FormatResultPanel` viste "1 UP (Rødt lag)"
|
||||
-- kryssjekket for hånd at Rødt lag (høyere kombinert CH) mottar
|
||||
slag på det vanskeligste hullet og derfor vinner nettoduellen 5 mot 5.
|
||||
Ekte typesjekket + full produksjonsbuild kjørt på nytt ETTER
|
||||
bug-fiksene (ikke bare før), alle 24 ruter listet.
|
||||
**Rullet ut live 2026-07-28**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon (ren frontend), `docker compose up -d --build
|
||||
teecup_frontend` (gjenskapte også `teecup_api` som vanlig bivirkning,
|
||||
ingen backend-kode rørt). Begge containere boot-et rent, `/health`/
|
||||
`/dashboard` → 200, `teeoff.no` upåvirket. **ADR-039 er dermed
|
||||
fullstendig ferdig, backend og frontend, live.**
|
||||
|
||||
Neste steg:
|
||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||
|
|
@ -4286,3 +4506,12 @@ Neste steg:
|
|||
flighter i én frittstående runde fortsatt kun drøftet (se
|
||||
FEATURE_BACKLOG.md); (e) varsler koblet til venneforespørsler, ikke
|
||||
til rundehendelser ennå.
|
||||
11. **Ferdig, kun for historikk:** ekte spillformer (match/skins/fourball/
|
||||
foursome/greensome/scramble) for frittstående runder — backend
|
||||
(ADR-039 + minimums-spiller-håndhevelse) OG frontend (sideoppsett,
|
||||
skins-konfig i `/my-rounds/new`, matchstatus-/skins-tavle-visning,
|
||||
delt-ball-scorekort/-veiviser, `setup_complete`-gating) er nå BEGGE
|
||||
bygget, browserverifisert og live (se status 2026-07-28). Mulig
|
||||
fremtidig finpuss (ikke bedt om ennå): redigere et sidenavn i
|
||||
etterkant (kun opprett/slett finnes i dag), en tydeligere
|
||||
skins-poeng-forklaring i UI-et.
|
||||
|
|
|
|||
|
|
@ -2916,6 +2916,108 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
|
|||
|
||||
---
|
||||
|
||||
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT (ADR-039), IKKE ENNÅ rullet ut mot ekte systemer, frontend fortsatt ikke bygget
|
||||
|
||||
**Oppdatering 2026-07-28 (ADR-039):** design + backend er ferdig samme
|
||||
dag som punktet ble reist. Se CLAUDE.md-status 2026-07-28 for full
|
||||
detalj (migrasjon `031`, nye endepunkter, 96/96 scratch-sjekker). Kort:
|
||||
alle fire load-bærende spørsmål (sider/gruppering, omfang av par-/lag-
|
||||
underformater, skins-regler, HCP-tellestatus for delt-ball) avklart med
|
||||
bruker, hele match-play-motoren fra org-turneringer PORTERT uendret
|
||||
(ingen ny regnelogikk for match/fourball/foursome/greensome/scramble),
|
||||
kun skins fikk ekte ny motorkode. **Gjenstår:** frontend (ingen skjerm
|
||||
for sideoppsett/skins-konfig/matchstatus-visning ennå) og selve
|
||||
utrullingen mot ekte `teecup_db`/`teecup_api` — begge egne, separate
|
||||
neste steg.
|
||||
|
||||
Opprinnelig reist av brukeren rett etter at ADR-038 (faktisk HCP) ble
|
||||
rullet ut:
|
||||
"Når en singlerunde settes opp: Er dette en slagspillsrunde, en match
|
||||
mellom to spillere, skins, eller en par- eller lag-konkurranse. Avhengig
|
||||
av svaret så må hcp beregnes forskjellig, og også scorekortet vil se
|
||||
annerledes ut."
|
||||
|
||||
**Viktig presisering av hva som FINNES i dag, for å unngå forveksling:**
|
||||
`round.play_format` (`'stroke'`/`'match'`, ADR-038) er i dag KUN en
|
||||
selvdeklarert ETIKETT som styrer én ting — et forslag om å ekskludere
|
||||
runden fra faktisk-HCP-grunnlaget. Den endrer INGENTING ved selve
|
||||
scoringsmodellen eller scorekortet — en "matchspill"-runde i dag
|
||||
registreres og vises identisk med en slagspill-runde (rå slag per hull
|
||||
per spiller). Dette nye punktet ber om noe vesentlig større: at
|
||||
spilleform faktisk STYRER både HCP-beregningen og scorekort-
|
||||
presentasjonen, for fire distinkte typer.
|
||||
|
||||
**De fire spillformene, og hva som mangler for hver:**
|
||||
1. **Slagspill** — dagens modell, uendret. Allerede fullt bygget
|
||||
(Score Differential/AGS, ADR-033 Beslutning G).
|
||||
2. **Match (to spillere)** — trenger match-play-slagfordeling
|
||||
(`match_play_strokes` i `handicap_engine.py`, allerede bygget/testet
|
||||
for turnering-matcher) i stedet for `allocate_strokes_by_index`, og et
|
||||
scorekort som viser løpende matchstatus (hull for hull vunnet/tapt/
|
||||
delt + "X UP"/"AS", samme presentasjonsspråk som
|
||||
`session-scorecard.tsx` allerede har for turnering-matcher) i stedet
|
||||
for rå slagsummer. **Reelt skjemahull:** `round_participant` har i dag
|
||||
INGEN "hvem spiller mot hvem"-kobling — en runde med 3+ deltakere har
|
||||
ingen måte å si at akkurat to av dem utgjør matchen.
|
||||
3. **Skins** — hull-for-hull-konkurranse der laveste (netto eller
|
||||
brutto) score på hullet vinner en "skin", uavgjort hull ruller
|
||||
premien videre til neste hull. **Ingen eksisterende motorstøtte i det
|
||||
hele tatt** — verken en beregningsfunksjon eller noen skjema-plass for
|
||||
"skins vunnet"/gjeldende premieverdi. Må designes fra bunnen
|
||||
(inkl. avklaring: netto eller brutto skins, rullerer uavgjort-verdien
|
||||
videre eller deles, minst 3 spillere).
|
||||
4. **Par- eller lag-konkurranse** — fourball/foursome/greensome/scramble-
|
||||
type spill blant rundens deltakere. Motoren for AKKURAT dette
|
||||
(`Format`-enum, `AllowanceStrategy`-familien, `unit_playing_handicap`)
|
||||
er allerede bygget og testet — men KUN brukt av den org-scopede
|
||||
turnering-modellen (`match`/`match_participant` under `session`).
|
||||
**Reelt skjemahull, samme klasse som punkt 2:** frittstående
|
||||
`round_participant`-rader er i dag rene individer uten noe
|
||||
par-/lag-konsept — ingen kobling for "disse to er makkere denne
|
||||
runden."
|
||||
|
||||
**Sentral arkitektur-spenning, verdt å legge merke til FØR design
|
||||
starter:** punkt 2 og 4 er strukturelt nesten IDENTISKE med det
|
||||
`app/routers/matches.py`/`scoring.py` allerede gjør for org-scopede
|
||||
turneringer (samme `Format`-enum, samme match-play-motor) — bare uten en
|
||||
organisasjon rundt. Dette overlapper direkte med det ennå ubesluttede
|
||||
spørsmålet i ADR-037 ("individuelle turneringer") og det tidligere
|
||||
presiserte "flere flighter i én frittstående runde"-spørsmålet (se egen
|
||||
seksjon over) — tre beslektede, men foreløpig separat behandlede
|
||||
problemstillinger som alle til slutt lander på "hvordan grupperer/
|
||||
parer vi deltakere, og hvilken motor regner poeng fra rå slag." Bør
|
||||
trolig avklares SAMMEN, ikke som tre uavhengige design-runder, for å
|
||||
unngå tre parallelle, litt ulike implementasjoner av i bunn og grunn
|
||||
samme idé.
|
||||
|
||||
**Åpne spørsmål, ingen besvart ennå:**
|
||||
- Skal `round.play_format` utvides med `'skins'`/`'pair_team'` (ny
|
||||
migrasjon, ny CHECK-verdi), eller er dette en helt egen entitet
|
||||
parallelt med `round`?
|
||||
- Match/par-lag: hvordan velges/lagres hvem som spiller mot/med hvem —
|
||||
ved oppsett (som blind draw for turneringer), eller fritt valgt av
|
||||
eieren i etterkant?
|
||||
- Skins: netto eller brutto, og hvordan behandles uavgjorte hull
|
||||
(rullerer premien, eller deles)?
|
||||
- Skal scorekortets NYE presentasjoner (matchstatus, skins-tavle,
|
||||
side-score) bygges som varianter av eksisterende komponenter
|
||||
(`round-scorecard.tsx`/`round-detail.tsx`), eller gjenbruke turnering-
|
||||
sidens `session-scorecard.tsx`-språk direkte?
|
||||
- Hvordan påvirker dette allerede byggede ADR-038 (faktisk HCP)? Match/
|
||||
skins/par-lag-runder trenger sannsynligvis EGNE regler for om/hvordan
|
||||
de teller mot faktisk HCP (samme "matchspill telles vanligvis ikke"-
|
||||
resonnement som allerede finnes for `'match'`, men skins/par-lag er
|
||||
ikke vurdert i det hele tatt ennå).
|
||||
|
||||
**Ingen kode skrevet** — dette er bevisst kun fanget/dokumentert nå, på
|
||||
brukerens eksplisitte instruks. Trenger en egen, dedikert design-/
|
||||
ADR-runde (samme skala som ADR-033/036/037) før noe bygges, gitt at
|
||||
punkt 2-4 hver krever nytt skjema, ny motorlogikk (for skins) eller
|
||||
gjenbruk av eksisterende turnering-motor (for match/par-lag), og en
|
||||
egen scorekort-presentasjon per format.
|
||||
|
||||
---
|
||||
|
||||
## Én person, flere e-postadresser — DEL 1 (det enkle tilfellet) ✅ BYGGET OG LIVE 2026-07-21, DEL 2 (kontosammenslåing) fortsatt 📋 NOTERT
|
||||
|
||||
Reist av brukeren rett etter ADR-032 (verifisert e-postbytte). Et beslektet,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -31,7 +31,7 @@ from handicap_engine import (
|
|||
# Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret,
|
||||
# greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er
|
||||
# ikke med her: der er enheten spilleren, og beregnes individuelt.
|
||||
_SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
|
||||
SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
|
||||
|
||||
FORMAT_UNIT_SIZE: dict[str, int] = {
|
||||
"singles": 1,
|
||||
|
|
@ -134,7 +134,7 @@ async def compute_and_store_side_handicaps(
|
|||
team_side,
|
||||
)
|
||||
|
||||
if format_ in _SIDE_IS_UNIT:
|
||||
if format_ in SIDE_IS_UNIT:
|
||||
expected = FORMAT_UNIT_SIZE[format_]
|
||||
total_on_side = await conn.fetchval(
|
||||
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
|
||||
|
|
@ -162,7 +162,7 @@ async def compute_and_store_side_handicaps(
|
|||
for p in participants
|
||||
]
|
||||
|
||||
if format_ in _SIDE_IS_UNIT:
|
||||
if format_ in SIDE_IS_UNIT:
|
||||
playing = config.strategy.playing_handicap(course_handicaps)
|
||||
for p, ch in zip(participants, course_handicaps):
|
||||
await conn.execute(
|
||||
|
|
@ -196,7 +196,7 @@ async def relative_strokes_for_match(
|
|||
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
|
||||
"kan ikke avgjøre noen hull ennå".
|
||||
"""
|
||||
if format_ in _SIDE_IS_UNIT:
|
||||
if format_ in SIDE_IS_UNIT:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap
|
||||
|
|
|
|||
|
|
@ -43,18 +43,28 @@ from .. import teeoff_client
|
|||
from ..auth import CurrentUser, get_current_user, get_current_user_from_websocket
|
||||
from ..db import plain_connection
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..handicap import SIDE_IS_UNIT, parse_allowance_config
|
||||
from ..realtime import broadcast_round_update, live_sockets_for_round
|
||||
from handicap_engine import (
|
||||
HoleResult,
|
||||
adjusted_gross_score,
|
||||
allocate_over_played_holes,
|
||||
allocate_strokes_by_index,
|
||||
apply_index_caps,
|
||||
compute_match_state,
|
||||
compute_skins,
|
||||
course_handicap,
|
||||
course_handicap_raw,
|
||||
handicap_index_from_differentials,
|
||||
low_handicap_index,
|
||||
match_play_strokes,
|
||||
round_counts_for_handicap,
|
||||
round_half_up,
|
||||
score_differential,
|
||||
)
|
||||
|
||||
_RESULT_LABEL = {HoleResult.SIDE_A: "a", HoleResult.SIDE_B: "b", HoleResult.HALVED: "halved"}
|
||||
|
||||
router = APIRouter(tags=["rounds"])
|
||||
|
||||
|
||||
|
|
@ -399,6 +409,148 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
|
|||
return resolved
|
||||
|
||||
|
||||
async def _resolve_round_course(round_row) -> _ResolvedCourse:
|
||||
if round_row["course_source"] == "teeoff":
|
||||
return await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
|
||||
return await _resolve_personal_course(round_row["personal_course_id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sider og match-play-håndtering (ADR-039) -- porterer det allerede
|
||||
# bevisste, produksjonskjørte mønsteret fra `app/handicap.py` sin
|
||||
# `compute_and_store_side_handicaps`/`relative_strokes_for_match`
|
||||
# (org-scopede turnering-matcher) til frittstående runder. SAMME motor
|
||||
# (handicap_engine.py), samme allowance-familie -- ingen ny regnelogikk
|
||||
# for match/fourball/foursome/greensome/scramble, kun andre tabeller.
|
||||
# Ingen allowance_override-støtte i v1 (kun DEFAULT_MATCHPLAY_ALLOWANCES).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _recompute_side_handicaps(conn, round_id: str, play_format: str) -> None:
|
||||
"""Beregn og lagre round_participant.playing_handicap for begge sider.
|
||||
|
||||
Rører ALDRI course_handicap_snapshot (den absolutte, individuelle WHS-
|
||||
verdien -- brukt uendret av slagspill-pipelinen) -- playing_handicap er
|
||||
en egen, ny kolonne kun for den format-relative match-play-enheten.
|
||||
"""
|
||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
|
||||
config = parse_allowance_config(engine_format, None)
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
|
||||
sides = await conn.fetch("SELECT id::text AS id FROM round_side WHERE round_id = $1", round_id)
|
||||
for side in sides:
|
||||
participants = await conn.fetch(
|
||||
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_side_id = $1",
|
||||
side["id"],
|
||||
)
|
||||
if len(participants) != expected or any(p["course_handicap_snapshot"] is None for p in participants):
|
||||
continue # ikke komplett ennå, eller mangler HCP-sporing for noen
|
||||
|
||||
if not config.use_handicap:
|
||||
for p in participants:
|
||||
await conn.execute("UPDATE round_participant SET playing_handicap = 0 WHERE id = $1", p["id"])
|
||||
continue
|
||||
|
||||
course_handicaps = [float(p["course_handicap_snapshot"]) for p in participants]
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
playing = config.strategy.playing_handicap(course_handicaps)
|
||||
for p in participants:
|
||||
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
|
||||
else:
|
||||
for p, ch in zip(participants, course_handicaps):
|
||||
playing = config.strategy.playing_handicap([ch])
|
||||
await conn.execute("UPDATE round_participant SET playing_handicap = $1 WHERE id = $2", playing, p["id"])
|
||||
|
||||
|
||||
async def _relative_strokes_for_round(conn, round_id: str, play_format: str) -> dict[str, int]:
|
||||
"""Relative slag per scoringsenhet (porterer relative_strokes_for_match).
|
||||
|
||||
Nøkkel = round_participant_id for match/fourball (fourball: begge sidene
|
||||
samlet i ÉN liste før match_play_strokes, jf. R&A-regelen om at laveste
|
||||
av alle fire spiller av scratch). Nøkkel = round_side_id for foursome/
|
||||
greensome/scramble. Returnerer {} hvis playing_handicap ikke er klart
|
||||
for én eller flere enheter ennå.
|
||||
"""
|
||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
|
||||
config = parse_allowance_config(engine_format, None)
|
||||
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (round_side_id) round_side_id::text AS unit, playing_handicap
|
||||
FROM round_participant WHERE round_id = $1 AND round_side_id IS NOT NULL
|
||||
ORDER BY round_side_id
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id::text AS unit, playing_handicap FROM round_participant "
|
||||
"WHERE round_id = $1 AND round_side_id IS NOT NULL",
|
||||
round_id,
|
||||
)
|
||||
|
||||
if not rows or any(r["playing_handicap"] is None for r in rows):
|
||||
return {}
|
||||
|
||||
absolutes = [r["playing_handicap"] for r in rows]
|
||||
relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
|
||||
return {r["unit"]: v for r, v in zip(rows, relative)}
|
||||
|
||||
|
||||
async def _check_side_capacity(
|
||||
conn, side_id: str, play_format: str, exclude_participant_id: str | None = None
|
||||
) -> None:
|
||||
"""Avvis tydelig FØR en deltaker tildeles en side som allerede har
|
||||
forventet antall spillere for formatet (2026-07-28 oppfølging) -- i
|
||||
stedet for å oppdage overtallet først ved fullføring. `exclude_
|
||||
participant_id` unngår at en deltaker som allerede ER på siden (f.eks.
|
||||
en no-op-reassignment til samme side) teller seg selv ut av plass."""
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
current_count = await conn.fetchval(
|
||||
"SELECT count(*) FROM round_participant WHERE round_side_id = $1 AND id != $2",
|
||||
side_id, exclude_participant_id or "00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
if current_count >= expected:
|
||||
raise app_error(
|
||||
409, "SIDE_FULL",
|
||||
f"Denne siden har allerede {expected} spiller(e) -- maks for {play_format}.",
|
||||
)
|
||||
|
||||
|
||||
async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[bool, str | None]:
|
||||
"""Er rundens deltaker-/side-oppsett komplett for FORMATET (2026-07-28
|
||||
oppfølging): match/skins/fourball/foursome/greensome/scramble krever
|
||||
flere spillere enn slagspill -- ingenting fanget dette opp før nå.
|
||||
Returnerer (komplett, forklarende melding hvis ikke)."""
|
||||
if play_format == "stroke":
|
||||
return True, None
|
||||
|
||||
if play_format == "skins":
|
||||
total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id)
|
||||
if total < _SKINS_MIN_PARTICIPANTS:
|
||||
return False, f"Skins krever minst {_SKINS_MIN_PARTICIPANTS} spillere -- runden har {total}."
|
||||
return True, None
|
||||
|
||||
# To-sidede formater (match/fourball/foursome/greensome/scramble_*).
|
||||
expected = _SIDE_PLAYER_COUNT[play_format]
|
||||
sides = await conn.fetch("SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id)
|
||||
if len(sides) != 2:
|
||||
return False, "Denne spilleformen krever nøyaktig to sider -- opprett begge før runden fullføres."
|
||||
|
||||
unassigned = await conn.fetchval(
|
||||
"SELECT count(*) FROM round_participant WHERE round_id = $1 AND round_side_id IS NULL", round_id
|
||||
)
|
||||
if unassigned > 0:
|
||||
return False, f"{unassigned} deltaker(e) er ikke tildelt noen side ennå."
|
||||
|
||||
for side in sides:
|
||||
count = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_side_id = $1", side["id"])
|
||||
if count != expected:
|
||||
label = side["label"] or "En side"
|
||||
return False, f"{label} har {count} spiller(e), men trenger nøyaktig {expected}."
|
||||
return True, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -408,6 +560,29 @@ async def _resolve_personal_course(personal_course_id: str) -> _ResolvedCourse:
|
|||
# av som default. GIR krever putts og vises derfor ikke i strokes_only.
|
||||
StatLevel = Literal["strokes_only", "strokes_and_putts", "full"]
|
||||
|
||||
# ADR-039: formater som bruker et to-siders-konsept (Beslutning A). "match"
|
||||
# er rundens eget navn for singles match play -- mappes til motorens eget
|
||||
# Format-enum-navn ("singles") kun når handicap_engine.py/app.handicap
|
||||
# kalles, ikke noe sted ellers.
|
||||
_TWO_SIDED_FORMATS = {"match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"}
|
||||
_ROUND_FORMAT_TO_ENGINE_FORMAT = {
|
||||
"match": "singles",
|
||||
"fourball": "fourball",
|
||||
"foursome": "foursome",
|
||||
"greensome": "greensome",
|
||||
"scramble_2": "scramble_2",
|
||||
"scramble_4": "scramble_4",
|
||||
}
|
||||
# Antall SPILLERE forventet per side -- IKKE det samme som app.handicap sin
|
||||
# FORMAT_UNIT_SIZE (som er HCP-BEREGNINGS-enheten: fourball beregnes
|
||||
# individuelt der, men har likevel 2 spillere per side for selve
|
||||
# matchresultat-sammenligningen).
|
||||
_SIDE_PLAYER_COUNT = {"match": 1, "fourball": 2, "foursome": 2, "greensome": 2, "scramble_2": 2, "scramble_4": 4}
|
||||
# Bekreftet av bruker (2026-07-28): 2 spillere gjør skins i praksis
|
||||
# identisk med en vanlig match -- 3+ er der en oppsamlet, uavgjort pott
|
||||
# faktisk gir mening som eget spillformat.
|
||||
_SKINS_MIN_PARTICIPANTS = 3
|
||||
|
||||
|
||||
class RoundCreate(BaseModel):
|
||||
course_source: Literal["teeoff", "custom"]
|
||||
|
|
@ -425,12 +600,21 @@ class RoundCreate(BaseModel):
|
|||
# Utslagstidspunkt (2026-07-24) -- valgfritt, brukt til å beregne
|
||||
# tidsbruk (completed_at - started_at) når runden fullføres.
|
||||
started_at: datetime | None = None
|
||||
# Selvdeklarert spilleform (ADR-038 Beslutning D) -- ingen egen
|
||||
# match-motor for frittstående runder, så dette taes brukerens ord for.
|
||||
play_format: Literal["stroke", "match"] = "stroke"
|
||||
# Selvdeklarert spilleform (ADR-038 Beslutning D, utvidet ADR-039) --
|
||||
# ingen egen "oppdag selv"-motor for frittstående runder, så dette taes
|
||||
# brukerens ord for. match/fourball/foursome/greensome/scramble_*
|
||||
# trenger sider (opprettes separat via POST .../sides, se ADR-039
|
||||
# Beslutning A) -- IKKE noe som settes opp i selve opprett-kallet.
|
||||
play_format: Literal[
|
||||
"stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"
|
||||
] = "stroke"
|
||||
# Frontend forhåndsutfyller denne til True når play_format='match'
|
||||
# (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse.
|
||||
exclude_owner_from_handicap: bool = False
|
||||
# KUN meningsfulle (og påkrevd) når play_format='skins' -- ADR-039
|
||||
# Beslutning D. Brukeren som setter opp runden velger begge uavhengig.
|
||||
skins_scoring: Literal["net", "gross"] | None = None
|
||||
skins_tie_handling: Literal["carry", "split"] | None = None
|
||||
|
||||
|
||||
class RoundParticipantOut(BaseModel):
|
||||
|
|
@ -461,6 +645,18 @@ class RoundParticipantOut(BaseModel):
|
|||
# counts_for_handicap. Kontrolleres av DENNE deltakeren selv (eller
|
||||
# runde-eieren), se update_participant.
|
||||
exclude_from_handicap: bool
|
||||
# ADR-039 Beslutning A -- hvilken side deltakeren er på (match/fourball/
|
||||
# foursome/greensome/scramble_*), null for slagspill/skins (ingen sider).
|
||||
round_side_id: str | None
|
||||
# ADR-039 Beslutning B -- den format-avledede, RELATIVE match-play-
|
||||
# enheten (fra AllowanceStrategy), IKKE samme som course_handicap_
|
||||
# snapshot (som forblir den absolutte, individuelle WHS-verdien).
|
||||
playing_handicap: int | None
|
||||
|
||||
|
||||
class RoundSideOut(BaseModel):
|
||||
id: str
|
||||
label: str | None
|
||||
|
||||
|
||||
class RoundOut(BaseModel):
|
||||
|
|
@ -473,9 +669,18 @@ class RoundOut(BaseModel):
|
|||
start_hole: int
|
||||
holes_planned: int
|
||||
play_format: str
|
||||
skins_scoring: str | None
|
||||
skins_tie_handling: str | None
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
participants: list[RoundParticipantOut]
|
||||
# ADR-039 Beslutning A -- tom liste for formater uten sider (slagspill/skins).
|
||||
sides: list[RoundSideOut]
|
||||
# 2026-07-28 oppfølging -- match/skins/fourball/foursome/greensome/
|
||||
# scramble krever flere spillere enn slagspill, håndhevet i
|
||||
# complete_round. setup_message er None når setup_complete er sann.
|
||||
setup_complete: bool
|
||||
setup_message: str | None
|
||||
# Den SPØRRENDE brukerens (eier ELLER en lenket medspiller, ADR-036
|
||||
# fase 3-utvidelsen 2026-07-26) egen fremdrift/score, utledet fra
|
||||
# round_hole (aldri lagret) -- omdøpt fra `owner_*` til `my_*` samme
|
||||
|
|
@ -490,7 +695,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
round_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot,
|
||||
played_at, start_hole, holes_planned, play_format, started_at, completed_at
|
||||
played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling,
|
||||
started_at, completed_at
|
||||
FROM round WHERE id = $1
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -503,13 +709,17 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
|
||||
rp.playing_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.round_id = $1 ORDER BY rp.is_owner DESC, rp.created_at
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
side_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id
|
||||
)
|
||||
# Viewer-relativt (ADR-036 fase 3-utvidelsen 2026-07-26): en lenket
|
||||
# medspiller som ser SIN EGEN "Egne runder"-liste skal se SIN EGEN
|
||||
# fremdrift her, ikke alltid eierens. Faller tilbake til eierens rad
|
||||
|
|
@ -532,6 +742,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
my_score_to_par = (
|
||||
viewer_agg["total_score"] - viewer_agg["total_par"] if my_holes_played > 0 else None
|
||||
)
|
||||
setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
|
||||
return RoundOut(
|
||||
id=round_row["id"],
|
||||
course_source=round_row["course_source"],
|
||||
|
|
@ -542,9 +753,14 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut:
|
|||
start_hole=round_row["start_hole"],
|
||||
holes_planned=round_row["holes_planned"],
|
||||
play_format=round_row["play_format"],
|
||||
skins_scoring=round_row["skins_scoring"],
|
||||
skins_tie_handling=round_row["skins_tie_handling"],
|
||||
started_at=round_row["started_at"].isoformat() if round_row["started_at"] else None,
|
||||
completed_at=round_row["completed_at"].isoformat() if round_row["completed_at"] else None,
|
||||
participants=[RoundParticipantOut(**dict(r)) for r in participant_rows],
|
||||
sides=[RoundSideOut(**dict(r)) for r in side_rows],
|
||||
setup_complete=setup_complete,
|
||||
setup_message=setup_message,
|
||||
my_holes_played=my_holes_played,
|
||||
my_total_score=my_total_score,
|
||||
my_score_to_par=my_score_to_par,
|
||||
|
|
@ -565,6 +781,8 @@ async def _create_participant(
|
|||
stat_level: StatLevel = "strokes_only",
|
||||
guest_email: str | None = None,
|
||||
exclude_from_handicap: bool = False,
|
||||
round_side_id: str | None = None,
|
||||
individual_round_hole: bool = True,
|
||||
) -> str:
|
||||
rating = resolved.rating_for(tee_name, gender)
|
||||
course_rating = slope_rating = tee_par = None
|
||||
|
|
@ -579,8 +797,8 @@ async def _create_participant(
|
|||
INSERT INTO round_participant
|
||||
(round_id, user_id, guest_name, guest_email, is_owner, gender, tee_name_snapshot, handicap_index_snapshot,
|
||||
course_rating_snapshot, slope_rating_snapshot, tee_par_snapshot, course_handicap_snapshot,
|
||||
stat_level, exclude_from_handicap)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
stat_level, exclude_from_handicap, round_side_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
round_id,
|
||||
|
|
@ -597,17 +815,23 @@ async def _create_participant(
|
|||
course_handicap_snapshot,
|
||||
stat_level,
|
||||
exclude_from_handicap,
|
||||
round_side_id,
|
||||
)
|
||||
participant_id = participant_row["id"]
|
||||
|
||||
for hole_number, par, stroke_index in resolved.holes:
|
||||
await conn.execute(
|
||||
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
||||
participant_id,
|
||||
hole_number,
|
||||
par,
|
||||
stroke_index,
|
||||
)
|
||||
# ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/
|
||||
# scramble) lagrer scoren PÅ SIDEN (round_hole.round_side_id, opprettet
|
||||
# samtidig med selve siden, se create_round_side), ikke per deltaker --
|
||||
# ingen individuelle round_hole-rader for disse.
|
||||
if individual_round_hole:
|
||||
for hole_number, par, stroke_index in resolved.holes:
|
||||
await conn.execute(
|
||||
"INSERT INTO round_hole (round_participant_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
||||
participant_id,
|
||||
hole_number,
|
||||
par,
|
||||
stroke_index,
|
||||
)
|
||||
return participant_id
|
||||
|
||||
|
||||
|
|
@ -625,6 +849,17 @@ async def create_round(
|
|||
raise app_error(400, "VALIDATION_FAILED", "personal_course_id er påkrevd.")
|
||||
resolved = await _resolve_personal_course(body.personal_course_id)
|
||||
|
||||
# ADR-039 Beslutning D -- skins-konfigurasjon er påkrevd for skins, og
|
||||
# gir ikke mening for noe annet format.
|
||||
if body.play_format == "skins":
|
||||
if body.skins_scoring is None or body.skins_tie_handling is None:
|
||||
raise app_error(
|
||||
400, "VALIDATION_FAILED",
|
||||
"skins_scoring og skins_tie_handling er påkrevd når play_format='skins'.",
|
||||
)
|
||||
elif body.skins_scoring is not None or body.skins_tie_handling is not None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "skins_scoring/skins_tie_handling gir kun mening for play_format='skins'.")
|
||||
|
||||
async with plain_connection() as conn:
|
||||
owner_row = await conn.fetchrow(
|
||||
"SELECT gender, handicap_index::float AS handicap_index FROM app_user WHERE id = $1",
|
||||
|
|
@ -645,8 +880,9 @@ async def create_round(
|
|||
INSERT INTO round
|
||||
(owner_user_id, course_source, teeoff_facility_slug, teeoff_course_id,
|
||||
personal_course_id, course_name_snapshot, tee_name_snapshot, played_at,
|
||||
start_hole, holes_planned, started_at, name, play_format)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
start_hole, holes_planned, started_at, name, play_format,
|
||||
skins_scoring, skins_tie_handling)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING id::text AS id
|
||||
""",
|
||||
user.user_id,
|
||||
|
|
@ -662,6 +898,8 @@ async def create_round(
|
|||
body.started_at,
|
||||
body.name.strip() if body.name and body.name.strip() else None,
|
||||
body.play_format,
|
||||
body.skins_scoring,
|
||||
body.skins_tie_handling,
|
||||
)
|
||||
round_id = round_row["id"]
|
||||
|
||||
|
|
@ -671,6 +909,7 @@ async def create_round(
|
|||
gender=owner_row["gender"], handicap_index=owner_row["handicap_index"],
|
||||
stat_level=body.stat_level,
|
||||
exclude_from_handicap=body.exclude_owner_from_handicap,
|
||||
individual_round_hole=body.play_format not in SIDE_IS_UNIT,
|
||||
)
|
||||
|
||||
return await _load_round_out(conn, round_id, user.user_id)
|
||||
|
|
@ -1289,6 +1528,10 @@ class ParticipantCreate(BaseModel):
|
|||
# gjest har ingen computed_handicap_index å beskytte). Frontend
|
||||
# forhåndsutfyller True når rundens play_format='match'.
|
||||
exclude_from_handicap: bool = False
|
||||
# ADR-039 Beslutning A -- valgfritt, kun gyldig når rundens play_format
|
||||
# bruker sider (match/fourball/foursome/greensome/scramble_*). Kan også
|
||||
# settes/endres senere via PATCH .../participants/{id}.
|
||||
round_side_id: str | None = None
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/participants", response_model=RoundParticipantOut, status_code=201)
|
||||
|
|
@ -1305,7 +1548,8 @@ async def add_participant(
|
|||
async with plain_connection() as conn:
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT owner_user_id::text AS owner_user_id, course_source, teeoff_facility_slug, "
|
||||
"teeoff_course_id, personal_course_id, tee_name_snapshot FROM round WHERE id = $1",
|
||||
"teeoff_course_id, personal_course_id, tee_name_snapshot, play_format, completed_at "
|
||||
"FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
if round_row is None:
|
||||
|
|
@ -1313,10 +1557,17 @@ async def add_participant(
|
|||
if round_row["owner_user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_AUTHORIZED", "Du eier ikke denne runden.")
|
||||
|
||||
if round_row["course_source"] == "teeoff":
|
||||
resolved = await _resolve_teeoff_course(round_row["teeoff_facility_slug"], int(round_row["teeoff_course_id"]))
|
||||
else:
|
||||
resolved = await _resolve_personal_course(round_row["personal_course_id"])
|
||||
if body.round_side_id is not None:
|
||||
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
side_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", body.round_side_id, round_id
|
||||
)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
|
||||
await _check_side_capacity(conn, body.round_side_id, round_row["play_format"])
|
||||
|
||||
resolved = await _resolve_round_course(round_row)
|
||||
|
||||
if body.user_id is not None:
|
||||
if body.user_id == round_row["owner_user_id"]:
|
||||
|
|
@ -1359,7 +1610,11 @@ async def add_participant(
|
|||
stat_level=body.stat_level,
|
||||
guest_email=body.guest_email,
|
||||
exclude_from_handicap=body.exclude_from_handicap,
|
||||
round_side_id=body.round_side_id,
|
||||
individual_round_hole=round_row["play_format"] not in SIDE_IS_UNIT,
|
||||
)
|
||||
if body.round_side_id is not None:
|
||||
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
|
|
@ -1368,7 +1623,8 @@ async def add_participant(
|
|||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
|
||||
rp.playing_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
|
|
@ -1379,6 +1635,81 @@ async def add_participant(
|
|||
return RoundParticipantOut(**dict(row))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sider (ADR-039 Beslutning A) -- nøyaktig to per runde, kun for formater
|
||||
# som faktisk bruker sider. Håndhevet i app-laget (samme mønster som
|
||||
# ADR-011s to-lags-grense for org-turneringer), ikke en DB-constraint.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RoundSideCreate(BaseModel):
|
||||
label: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
@router.post("/rounds/{round_id}/sides", response_model=RoundSideOut, status_code=201)
|
||||
async def create_round_side(
|
||||
round_id: str,
|
||||
body: RoundSideCreate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundSideOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT play_format, course_source, teeoff_facility_slug, teeoff_course_id, "
|
||||
"personal_course_id, completed_at FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
if round_row["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
if round_row["completed_at"] is not None:
|
||||
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
|
||||
existing_count = await conn.fetchval("SELECT count(*) FROM round_side WHERE round_id = $1", round_id)
|
||||
if existing_count >= 2:
|
||||
raise app_error(409, "LIMIT_REACHED", "En runde kan maks ha to sider.")
|
||||
|
||||
async with conn.transaction():
|
||||
side_row = await conn.fetchrow(
|
||||
"INSERT INTO round_side (round_id, label) VALUES ($1, $2) RETURNING id::text AS id, label",
|
||||
round_id, body.label,
|
||||
)
|
||||
# Delt-ball-formater (ADR-039 Beslutning C): siden får sine
|
||||
# round_hole-rader med en gang -- rating trengs ikke her, kun
|
||||
# par/stroke-index-snapshot, uavhengig av om siden har fått
|
||||
# noen deltakere ennå.
|
||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[round_row["play_format"]]
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
resolved = await _resolve_round_course(round_row)
|
||||
for hole_number, par, stroke_index in resolved.holes:
|
||||
await conn.execute(
|
||||
"INSERT INTO round_hole (round_side_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
|
||||
side_row["id"], hole_number, par, stroke_index,
|
||||
)
|
||||
await broadcast_round_update(round_id)
|
||||
return RoundSideOut(**dict(side_row))
|
||||
|
||||
|
||||
@router.delete("/rounds/{round_id}/sides/{side_id}", status_code=204)
|
||||
async def delete_round_side(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
async with plain_connection() as conn:
|
||||
await _get_owned_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow("SELECT completed_at FROM round WHERE id = $1", round_id)
|
||||
if round_row["completed_at"] is not None:
|
||||
raise app_error(409, "ALREADY_COMPLETED", "Runden er allerede fullført.")
|
||||
side_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id
|
||||
)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
# Kaskade fjerner sidens round_hole-rader; deltakere som var på
|
||||
# denne siden faller tilbake til round_side_id = NULL (ON DELETE
|
||||
# SET NULL) -- de forblir i runden, bare uten side-tilhørighet.
|
||||
await conn.execute("DELETE FROM round_side WHERE id = $1", side_id)
|
||||
await broadcast_round_update(round_id)
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/tee-options", response_model=list[TeeOption])
|
||||
async def get_round_tee_options(round_id: str, user: CurrentUser = Depends(get_current_user)) -> list[TeeOption]:
|
||||
"""Utslagene tilgjengelig på RUNDENS bane -- brukt av 'rediger spiller'-
|
||||
|
|
@ -1421,11 +1752,17 @@ class ParticipantUpdate(BaseModel):
|
|||
# ADR-038 Beslutning C -- eneste feltet en IKKE-eier (en lenket
|
||||
# medspiller som redigerer SIN EGEN rad) får sende her.
|
||||
exclude_from_handicap: bool | None = None
|
||||
# ADR-039 Beslutning A -- (re)tildel/fjern en deltakers side. Sendes som
|
||||
# `null` for å fjerne fra en side. Eier-only (se _OWNER_ONLY_
|
||||
# PARTICIPANT_FIELDS under).
|
||||
round_side_id: str | None = None
|
||||
|
||||
|
||||
# Felt en ikke-eier ALDRI kan sende her, uansett hvilken rad det gjelder --
|
||||
# eier-only, se update_participant sin autorisasjonssjekk.
|
||||
_OWNER_ONLY_PARTICIPANT_FIELDS = {"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email"}
|
||||
_OWNER_ONLY_PARTICIPANT_FIELDS = {
|
||||
"stat_level", "tee_name", "handicap_index", "guest_name", "gender", "guest_email", "round_side_id",
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}/participants/{participant_id}", response_model=RoundParticipantOut)
|
||||
|
|
@ -1450,7 +1787,7 @@ async def update_participant(
|
|||
rp.counts_for_handicap,
|
||||
r.owner_user_id::text AS round_owner_user_id,
|
||||
r.completed_at, r.course_source, r.teeoff_facility_slug,
|
||||
r.teeoff_course_id, r.personal_course_id
|
||||
r.teeoff_course_id, r.personal_course_id, r.play_format
|
||||
FROM round_participant rp
|
||||
JOIN round r ON r.id = rp.round_id
|
||||
WHERE rp.id = $1 AND rp.round_id = $2
|
||||
|
|
@ -1499,6 +1836,19 @@ async def update_participant(
|
|||
if "exclude_from_handicap" in updates:
|
||||
values.append(updates["exclude_from_handicap"])
|
||||
set_clauses.append(f"exclude_from_handicap = ${len(values)}")
|
||||
if "round_side_id" in updates:
|
||||
new_side_id = updates["round_side_id"]
|
||||
if new_side_id is not None:
|
||||
if current["play_format"] not in _TWO_SIDED_FORMATS:
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke sider.")
|
||||
side_exists = await conn.fetchval(
|
||||
"SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", new_side_id, round_id
|
||||
)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke på denne runden.")
|
||||
await _check_side_capacity(conn, new_side_id, current["play_format"], exclude_participant_id=participant_id)
|
||||
values.append(new_side_id)
|
||||
set_clauses.append(f"round_side_id = ${len(values)}")
|
||||
|
||||
if rating_changed:
|
||||
if current["course_source"] == "teeoff":
|
||||
|
|
@ -1546,6 +1896,13 @@ async def update_participant(
|
|||
f"UPDATE round_participant SET {', '.join(set_clauses)} WHERE id = ${len(values)} RETURNING id",
|
||||
*values,
|
||||
)
|
||||
|
||||
# Side-tildeling endret (ADR-039) -- regn playing_handicap for begge
|
||||
# sider på nytt FØR raden under leses, slik at responsen reflekterer
|
||||
# den ferske verdien (billig no-op hvis fortsatt ikke komplett).
|
||||
if "round_side_id" in updates and current["play_format"] in _TWO_SIDED_FORMATS:
|
||||
await _recompute_side_handicaps(conn, round_id, current["play_format"])
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
|
||||
|
|
@ -1554,7 +1911,8 @@ async def update_participant(
|
|||
rp.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||
rp.course_handicap_snapshot, rp.counts_for_handicap,
|
||||
rp.score_differential::float AS score_differential, rp.stat_level,
|
||||
rp.exclude_from_handicap
|
||||
rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id,
|
||||
rp.playing_handicap
|
||||
FROM round_participant rp
|
||||
LEFT JOIN app_user au ON au.id = rp.user_id
|
||||
WHERE rp.id = $1
|
||||
|
|
@ -1671,6 +2029,278 @@ async def list_holes(
|
|||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delt-ball-scoring (ADR-039 Beslutning C) -- foursome/greensome/scramble
|
||||
# lagrer ÉN score PER SIDE per hull, ikke per spiller. Ingen av de andre
|
||||
# detalj-feltene (kølle/retning/chip/bunker/straffeslag/putt-lengde) gir
|
||||
# entydig mening for en delt ball -- kun selve slagtallet.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RoundSideHoleOut(BaseModel):
|
||||
hole_number: int
|
||||
par: int
|
||||
stroke_index: int
|
||||
played: bool
|
||||
score: int | None
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/sides/{side_id}/holes", response_model=list[RoundSideHoleOut])
|
||||
async def list_side_holes(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[RoundSideHoleOut]:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
rows = await conn.fetch(
|
||||
"SELECT hole_number, par, stroke_index, played, score FROM round_hole "
|
||||
"WHERE round_side_id = $1 ORDER BY hole_number",
|
||||
side_id,
|
||||
)
|
||||
return [RoundSideHoleOut(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
class SideHoleUpdate(BaseModel):
|
||||
played: bool = True
|
||||
score: int | None = Field(default=None, ge=1, le=20)
|
||||
|
||||
|
||||
@router.patch("/rounds/{round_id}/sides/{side_id}/holes/{hole_number}", response_model=RoundSideHoleOut)
|
||||
async def update_side_hole(
|
||||
round_id: str,
|
||||
side_id: str,
|
||||
hole_number: int,
|
||||
body: SideHoleUpdate,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundSideHoleOut:
|
||||
async with plain_connection() as conn:
|
||||
# Samme begrunnelse som update_hole -- en medspiller som fører
|
||||
# score for flighten skal også kunne føre for en delt-ball-side.
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
side_exists = await conn.fetchval("SELECT 1 FROM round_side WHERE id = $1 AND round_id = $2", side_id, round_id)
|
||||
if not side_exists:
|
||||
raise app_error(404, "NOT_FOUND", "Siden finnes ikke.")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE round_hole SET played = $3, score = $4
|
||||
WHERE round_side_id = $1 AND hole_number = $2
|
||||
RETURNING hole_number, par, stroke_index, played, score
|
||||
""",
|
||||
side_id, hole_number, body.played, body.score,
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Hullet finnes ikke på denne siden.")
|
||||
await broadcast_round_update(round_id)
|
||||
return RoundSideHoleOut(**dict(row))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/
|
||||
# greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret),
|
||||
# samme "cache aldri det som kan regnes ut fra rå data"-filosofi som resten
|
||||
# av appen. Slagspill trenger ingen egen visning her -- eksisterende
|
||||
# scorekort/leaderboard dekker det allerede.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RoundFormatResultOut(BaseModel):
|
||||
play_format: str
|
||||
ready: bool # False = handicap ikke klart for én eller begge sider ennå
|
||||
# Match/fourball/foursome/greensome/scramble:
|
||||
match_lead: int | None = None
|
||||
match_holes_played: int | None = None
|
||||
match_holes_remaining: int | None = None
|
||||
match_is_closed: bool | None = None
|
||||
match_is_dormie: bool | None = None
|
||||
match_status_text: str | None = None
|
||||
hole_results: list[str] | None = None # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge
|
||||
# Skins:
|
||||
skins_won: dict[str, float] | None = None
|
||||
|
||||
|
||||
def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]:
|
||||
"""Samme sirkulære spillerekkefølge som frontend allerede bruker
|
||||
(round-detail.tsx) -- starter på start_hole, ruller rundt 1-18."""
|
||||
return [((start_hole - 1 + i) % 18) + 1 for i in range(holes_planned)]
|
||||
|
||||
|
||||
@router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut)
|
||||
async def get_round_format_result(
|
||||
round_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundFormatResultOut:
|
||||
async with plain_connection() as conn:
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow(
|
||||
"SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned "
|
||||
"FROM round WHERE id = $1",
|
||||
round_id,
|
||||
)
|
||||
play_format = round_row["play_format"]
|
||||
|
||||
if play_format in _TWO_SIDED_FORMATS:
|
||||
relative = await _relative_strokes_for_round(conn, round_id, play_format)
|
||||
if not relative:
|
||||
return RoundFormatResultOut(play_format=play_format, ready=False)
|
||||
|
||||
engine_format = _ROUND_FORMAT_TO_ENGINE_FORMAT[play_format]
|
||||
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
||||
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
# Delt ball -- én rad per SIDE per hull.
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rh.round_side_id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
||||
FROM round_hole rh
|
||||
JOIN round_side rs ON rs.id = rh.round_side_id
|
||||
WHERE rs.round_id = $1
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
else:
|
||||
# Individuell ball -- nøkkelen ("unit") er DELTAKEREN selv
|
||||
# (samme som _relative_strokes_for_round sin nøkkel for
|
||||
# disse formatene), IKKE siden -- hvilken side hver deltaker
|
||||
# tilhører slås opp separat via unit_to_side under.
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rp.id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
||||
FROM round_hole rh
|
||||
JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
|
||||
by_unit_hole: dict[tuple[str, int], list] = {}
|
||||
stroke_index_by_hole: dict[int, int] = {}
|
||||
for r in rows:
|
||||
if r["played"] and r["score"] is not None:
|
||||
by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"])
|
||||
stroke_index_by_hole[r["hole_number"]] = r["stroke_index"]
|
||||
all_18_si = [stroke_index_by_hole[h] for h in range(1, 19)] if len(stroke_index_by_hole) == 18 else None
|
||||
|
||||
side_ids = sorted(relative.keys()) if engine_format in SIDE_IS_UNIT else None
|
||||
if side_ids is None:
|
||||
# Individuelle enheter er round_participant_id-er -- hent
|
||||
# hvilken side hver enhet faktisk tilhører for å vite "a"/"b".
|
||||
unit_side = await conn.fetch(
|
||||
"SELECT id::text AS id, round_side_id::text AS side_id FROM round_participant WHERE round_id = $1",
|
||||
round_id,
|
||||
)
|
||||
unit_to_side = {r["id"]: r["side_id"] for r in unit_side}
|
||||
side_ids = sorted({s for s in unit_to_side.values() if s is not None})
|
||||
|
||||
if len(side_ids) != 2 or all_18_si is None:
|
||||
return RoundFormatResultOut(play_format=play_format, ready=False)
|
||||
|
||||
strokes_per_hole = {
|
||||
unit: dict(zip(range(1, 19), allocate_over_played_holes(v, all_18_si, list(range(1, 19)))))
|
||||
for unit, v in relative.items()
|
||||
}
|
||||
side_a_id, side_b_id = side_ids
|
||||
expected_players = _SIDE_PLAYER_COUNT[play_format]
|
||||
|
||||
def side_net(hole_number: int, side_id: str) -> int | None:
|
||||
if engine_format in SIDE_IS_UNIT:
|
||||
scores = by_unit_hole.get((side_id, hole_number), [])
|
||||
if len(scores) != 1:
|
||||
return None
|
||||
per_hole = strokes_per_hole.get(side_id)
|
||||
if per_hole is None:
|
||||
return None
|
||||
return scores[0] - per_hole[hole_number]
|
||||
nets = []
|
||||
for unit_id, unit_side_id in unit_to_side.items():
|
||||
if unit_side_id != side_id:
|
||||
continue
|
||||
scores = by_unit_hole.get((unit_id, hole_number), [])
|
||||
if len(scores) != 1:
|
||||
return None
|
||||
per_hole = strokes_per_hole.get(unit_id)
|
||||
if per_hole is None:
|
||||
return None
|
||||
nets.append(scores[0] - per_hole[hole_number])
|
||||
if len(nets) != expected_players:
|
||||
return None
|
||||
return min(nets)
|
||||
|
||||
hole_results: list[HoleResult] = []
|
||||
for hole_number in play_order:
|
||||
net_a = side_net(hole_number, side_a_id)
|
||||
net_b = side_net(hole_number, side_b_id)
|
||||
if net_a is None or net_b is None:
|
||||
break
|
||||
if net_a < net_b:
|
||||
hole_results.append(HoleResult.SIDE_A)
|
||||
elif net_b < net_a:
|
||||
hole_results.append(HoleResult.SIDE_B)
|
||||
else:
|
||||
hole_results.append(HoleResult.HALVED)
|
||||
|
||||
state = compute_match_state(hole_results, total_holes=round_row["holes_planned"])
|
||||
return RoundFormatResultOut(
|
||||
play_format=play_format,
|
||||
ready=True,
|
||||
match_lead=state.lead,
|
||||
match_holes_played=state.holes_played,
|
||||
match_holes_remaining=state.holes_remaining,
|
||||
match_is_closed=state.is_closed,
|
||||
match_is_dormie=state.is_dormie,
|
||||
match_status_text=state.describe(),
|
||||
hole_results=[_RESULT_LABEL[r] for r in hole_results],
|
||||
)
|
||||
|
||||
if play_format == "skins":
|
||||
participant_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, course_handicap_snapshot FROM round_participant WHERE round_id = $1",
|
||||
round_id,
|
||||
)
|
||||
hole_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT rp.id::text AS participant_id, rh.hole_number, rh.stroke_index, rh.played, rh.score
|
||||
FROM round_hole rh JOIN round_participant rp ON rp.id = rh.round_participant_id
|
||||
WHERE rp.round_id = $1
|
||||
ORDER BY rh.hole_number
|
||||
""",
|
||||
round_id,
|
||||
)
|
||||
strokes_by_participant: dict[str, dict[int, int]] = {}
|
||||
if round_row["skins_scoring"] == "net":
|
||||
by_participant_holes: dict[str, list] = {}
|
||||
for r in hole_rows:
|
||||
by_participant_holes.setdefault(r["participant_id"], []).append(r)
|
||||
ch_by_participant = {p["id"]: p["course_handicap_snapshot"] for p in participant_rows}
|
||||
for pid, holes in by_participant_holes.items():
|
||||
ch = ch_by_participant.get(pid)
|
||||
if ch is None:
|
||||
continue
|
||||
allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes])
|
||||
strokes_by_participant[pid] = {h["hole_number"]: a for h, a in zip(holes, allocation)}
|
||||
|
||||
scores_by_hole_number: dict[int, list[tuple[str, int]]] = {}
|
||||
for r in hole_rows:
|
||||
if not r["played"] or r["score"] is None:
|
||||
continue
|
||||
if round_row["skins_scoring"] == "net":
|
||||
per_hole = strokes_by_participant.get(r["participant_id"])
|
||||
if per_hole is None:
|
||||
continue
|
||||
value = r["score"] - per_hole[r["hole_number"]]
|
||||
else:
|
||||
value = r["score"]
|
||||
scores_by_hole_number.setdefault(r["hole_number"], []).append((r["participant_id"], value))
|
||||
|
||||
play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"])
|
||||
scores_by_hole = [scores_by_hole_number.get(h, []) for h in play_order]
|
||||
skins_won = compute_skins(scores_by_hole, tie_handling=round_row["skins_tie_handling"])
|
||||
return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won)
|
||||
|
||||
return RoundFormatResultOut(play_format=play_format, ready=True)
|
||||
|
||||
|
||||
class LeaderboardHoleOut(BaseModel):
|
||||
hole_number: int
|
||||
par: int
|
||||
|
|
@ -1890,7 +2520,15 @@ async def complete_round(round_id: str, user: CurrentUser = Depends(get_current_
|
|||
# Samme begrunnelse som update_hole -- en medspiller som fører
|
||||
# score for flighten skal også kunne markere runden ferdig.
|
||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||
round_row = await conn.fetchrow("SELECT holes_planned FROM round WHERE id = $1", round_id)
|
||||
round_row = await conn.fetchrow("SELECT holes_planned, play_format FROM round WHERE id = $1", round_id)
|
||||
|
||||
# 2026-07-28 oppfølging: match/skins/fourball/foursome/greensome/
|
||||
# scramble krever flere spillere enn slagspill -- ingenting fanget
|
||||
# dette opp før nå. Avvis tydelig FØR noe regnes ut, i stedet for å
|
||||
# stille fullføre en runde som er strukturelt umulig for formatet.
|
||||
setup_complete, setup_message = await _format_setup_status(conn, round_id, round_row["play_format"])
|
||||
if not setup_complete:
|
||||
raise app_error(409, "SETUP_INCOMPLETE", setup_message)
|
||||
|
||||
participants = await conn.fetch(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -71,9 +71,24 @@ type CourseMeta =
|
|||
|
||||
type Gender = "m" | "f"
|
||||
type StatLevel = "strokes_only" | "strokes_and_putts" | "full"
|
||||
// ADR-038 -- selvdeklarert spilleform. Ingen egen match-motor for
|
||||
// frittstående runder, så dette taes brukerens ord for.
|
||||
type PlayFormat = "stroke" | "match"
|
||||
// ADR-038/ADR-039 -- selvdeklarert spilleform. Ingen egen match-/skins-motor
|
||||
// for frittstående runder, så dette taes brukerens ord for. Sider (for de
|
||||
// to-sidede formatene) opprettes IKKE her -- det skjer i selve runden
|
||||
// (POST .../sides), siden det krever at deltakerne allerede finnes.
|
||||
type PlayFormat = "stroke" | "match" | "skins" | "fourball" | "foursome" | "greensome" | "scramble_2" | "scramble_4"
|
||||
type SkinsScoring = "net" | "gross"
|
||||
type SkinsTieHandling = "carry" | "split"
|
||||
|
||||
const PLAY_FORMAT_OPTIONS: { value: PlayFormat; label: string; desc: string }[] = [
|
||||
{ value: "stroke", label: "Slagspill", desc: "Vanlig individuell runde." },
|
||||
{ value: "match", label: "Match", desc: "To sider, hull mot hull." },
|
||||
{ value: "skins", label: "Skins", desc: "Hvert hull er en egen premie -- minst 3 spillere." },
|
||||
{ value: "fourball", label: "Fourball", desc: "To mot to, beste ball per side." },
|
||||
{ value: "foursome", label: "Foursome", desc: "To mot to, én delt ball, annenhver slår." },
|
||||
{ value: "greensome", label: "Greensome", desc: "To mot to, begge slår ut, velger så én ball." },
|
||||
{ value: "scramble_2", label: "Scramble (2)", desc: "To mot to, velger beste slag hver gang." },
|
||||
{ value: "scramble_4", label: "Scramble (4)", desc: "Fire mot fire, velger beste slag hver gang." },
|
||||
]
|
||||
|
||||
// --- API-typer ---------------------------------------------------------------
|
||||
|
||||
|
|
@ -203,36 +218,35 @@ export function NewRound() {
|
|||
statLevel: StatLevel
|
||||
playFormat: PlayFormat
|
||||
excludeFromHandicap: boolean
|
||||
skinsScoring: SkinsScoring | null
|
||||
skinsTieHandling: SkinsTieHandling | null
|
||||
}): Promise<{ id: string }> {
|
||||
if (!courseMeta) throw new Error("Mangler valgt bane")
|
||||
const shared = {
|
||||
tee_name: payload.teeName,
|
||||
name: payload.name || null,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
play_format: payload.playFormat,
|
||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
skins_scoring: payload.skinsScoring,
|
||||
skins_tie_handling: payload.skinsTieHandling,
|
||||
}
|
||||
const body =
|
||||
courseMeta.source === "teeoff"
|
||||
? {
|
||||
course_source: "teeoff",
|
||||
teeoff_facility_slug: courseMeta.facilitySlug,
|
||||
teeoff_course_id: courseMeta.teeoffCourseId,
|
||||
tee_name: payload.teeName,
|
||||
name: payload.name || null,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
play_format: payload.playFormat,
|
||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
...shared,
|
||||
}
|
||||
: {
|
||||
course_source: "custom",
|
||||
personal_course_id: courseMeta.personalCourseId,
|
||||
tee_name: payload.teeName,
|
||||
name: payload.name || null,
|
||||
played_at: payload.date,
|
||||
started_at: payload.startedAt,
|
||||
start_hole: payload.startHole,
|
||||
holes_planned: payload.holes,
|
||||
stat_level: payload.statLevel,
|
||||
play_format: payload.playFormat,
|
||||
exclude_owner_from_handicap: payload.excludeFromHandicap,
|
||||
...shared,
|
||||
}
|
||||
const res = await fetch("/rounds", {
|
||||
method: "POST",
|
||||
|
|
@ -1121,6 +1135,8 @@ function ConfirmStep({
|
|||
statLevel: StatLevel
|
||||
playFormat: PlayFormat
|
||||
excludeFromHandicap: boolean
|
||||
skinsScoring: SkinsScoring | null
|
||||
skinsTieHandling: SkinsTieHandling | null
|
||||
}) => Promise<{ id: string }>
|
||||
onCreated: (roundId: string) => void
|
||||
}) {
|
||||
|
|
@ -1134,17 +1150,27 @@ function ConfirmStep({
|
|||
const [statLevel, setStatLevel] = useState<StatLevel>("strokes_only")
|
||||
const [playFormat, setPlayFormat] = useState<PlayFormat>("stroke")
|
||||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(false)
|
||||
// ADR-039 Beslutning D -- kun meningsfullt (og påkrevd av backend) for
|
||||
// play_format="skins". Fornuftige forhåndsvalg, begge overstyrbare.
|
||||
const [skinsScoring, setSkinsScoring] = useState<SkinsScoring>("net")
|
||||
const [skinsTieHandling, setSkinsTieHandling] = useState<SkinsTieHandling>("carry")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
function choosePlayFormat(next: PlayFormat) {
|
||||
setPlayFormat(next)
|
||||
// ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når
|
||||
// matchspill velges. Brukeren kan uansett overstyre under.
|
||||
// ADR-038 Beslutning D -- foreslå (ikke tving) eksklusjon når match
|
||||
// velges. De andre formatene har egne HCP-regler (ADR-039 Beslutning E)
|
||||
// som ikke bruker dette feltet -- nullstill det da for å unngå et
|
||||
// misvisende forhåndsvalg som likevel ikke sendes videre meningsfullt.
|
||||
setExcludeFromHandicap(next === "match")
|
||||
}
|
||||
|
||||
const selectedTee = compatibleTees.find((t) => t.id === teeId)
|
||||
// ADR-039 Beslutning A -- match/fourball/foursome/greensome/scramble
|
||||
// krever sider, opprettet ETTERPÅ inne i selve runden (deltakerne må
|
||||
// finnes først). Vises som en forklarende note her, ikke et skjema.
|
||||
const needsSidesSetup = playFormat !== "stroke" && playFormat !== "skins"
|
||||
|
||||
async function handleStart() {
|
||||
if (!selectedTee) return
|
||||
|
|
@ -1165,6 +1191,8 @@ function ConfirmStep({
|
|||
statLevel,
|
||||
playFormat,
|
||||
excludeFromHandicap,
|
||||
skinsScoring: playFormat === "skins" ? skinsScoring : null,
|
||||
skinsTieHandling: playFormat === "skins" ? skinsTieHandling : null,
|
||||
})
|
||||
onCreated(created.id)
|
||||
} catch {
|
||||
|
|
@ -1311,32 +1339,33 @@ function ConfirmStep({
|
|||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Spilleform (ADR-038) -- selvdeklarert, ingen egen match-motor */}
|
||||
{/* Spilleform (ADR-038/ADR-039) -- selvdeklarert, ingen egen match-/
|
||||
skins-motor. match/fourball/foursome/greensome/scramble krever
|
||||
sider -- opprettes i selve runden etter at deltakerne er lagt til. */}
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<legend className="mb-1 text-base font-semibold text-foreground">Spilleform</legend>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(
|
||||
[
|
||||
{ value: "stroke" as const, label: "Slagspill" },
|
||||
{ value: "match" as const, label: "Matchspill" },
|
||||
]
|
||||
).map((opt) => (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{PLAY_FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => choosePlayFormat(opt.value)}
|
||||
aria-pressed={playFormat === opt.value}
|
||||
className={cn(
|
||||
"flex min-h-[44px] items-center justify-center rounded-2xl border px-4 py-3 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"flex min-h-[44px] flex-col items-center justify-center gap-0.5 rounded-2xl border px-3 py-3 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
playFormat === opt.value
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
<span className="text-base font-bold">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{PLAY_FORMAT_OPTIONS.find((o) => o.value === playFormat)?.desc}
|
||||
</p>
|
||||
|
||||
{playFormat === "match" && (
|
||||
<label className="flex min-h-[44px] items-start gap-3 rounded-2xl border border-border bg-card px-5 py-3">
|
||||
<input
|
||||
|
|
@ -1350,12 +1379,79 @@ function ConfirmStep({
|
|||
Ekskluder denne runden fra mitt faktiske HCP
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Anbefalt for matchspill -- konsederte hull gir ofte et upålitelig grunnlag for
|
||||
Anbefalt for match -- konsederte hull gir ofte et upålitelig grunnlag for
|
||||
HCP-beregning. Du kan endre dette senere.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{playFormat === "skins" && (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card px-5 py-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Poengberegning</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "net" as const, label: "Netto (m/HCP)" },
|
||||
{ value: "gross" as const, label: "Brutto" },
|
||||
]
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSkinsScoring(opt.value)}
|
||||
aria-pressed={skinsScoring === opt.value}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
|
||||
skinsScoring === opt.value
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-foreground">Ved uavgjort hull</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "carry" as const, label: "Rullerer over" },
|
||||
{ value: "split" as const, label: "Deles likt" },
|
||||
]
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSkinsTieHandling(opt.value)}
|
||||
aria-pressed={skinsTieHandling === opt.value}
|
||||
className={cn(
|
||||
"flex min-h-11 items-center justify-center rounded-xl border text-sm font-bold transition-colors",
|
||||
skinsTieHandling === opt.value
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skins krever minst 3 spillere i runden -- legg til medspillere etter at runden er opprettet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsSidesSetup && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du setter opp sidene (og fordeler spillerne) inne i runden, etter at medspillerne er
|
||||
lagt til.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Hole count */}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -24,7 +24,7 @@ from dataclasses import dataclass
|
|||
from datetime import date, timedelta
|
||||
from decimal import Decimal, ROUND_FLOOR
|
||||
from enum import Enum
|
||||
from typing import Protocol, Sequence
|
||||
from typing import Literal, Protocol, Sequence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -585,3 +585,50 @@ class Player:
|
|||
def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int:
|
||||
"""Playing Handicap for én enhet (spiller eller lag) gitt en strategi."""
|
||||
return strategy.playing_handicap([p.course_handicap_raw() for p in players])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skins (ADR-039 Beslutning D) -- ikke et WHS/R&A-regulert format, ingen
|
||||
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_skins(
|
||||
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
||||
tie_handling: Literal["carry", "split"] = "carry",
|
||||
) -> dict[str, float]:
|
||||
"""Skins vunnet per deltaker, gitt scorer hull for hull.
|
||||
|
||||
`scores_by_hole[i]` er (deltaker_id, score)-par for hull i+1 -- KUN for
|
||||
deltakere som faktisk har registrert en score på det hullet. Scoren skal
|
||||
allerede være avgjort NETTO eller BRUTTO av kalleren FØR denne funksjonen
|
||||
kalles (`round.skins_scoring`) -- motoren selv vet ikke, og bryr seg ikke
|
||||
om, hvilket.
|
||||
|
||||
- "carry": et uavgjort hull sitt "skinn" (og alt tidligere oppsamlet)
|
||||
ruller videre til neste hull -- førstemann til en ENTYDIG laveste
|
||||
score der tar HELE potten.
|
||||
- "split": et uavgjort hull sitt skinn (og alt oppsamlet) deles LIKT
|
||||
mellom de tied spillerne i stedet for å rulle videre -- kan gi
|
||||
brøkdeler, derav float i returtypen.
|
||||
|
||||
Et hull uten noen registrerte scorer hopper stille over (verken potten
|
||||
eller noen vinnere endres) -- ikke en feiltilstand.
|
||||
"""
|
||||
winnings: dict[str, float] = {}
|
||||
pot = 0.0
|
||||
for hole_scores in scores_by_hole:
|
||||
if not hole_scores:
|
||||
continue
|
||||
pot += 1.0
|
||||
lowest = min(score for _, score in hole_scores)
|
||||
winners = [pid for pid, score in hole_scores if score == lowest]
|
||||
if len(winners) == 1:
|
||||
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
|
||||
pot = 0.0
|
||||
elif tie_handling == "split":
|
||||
share = pot / len(winners)
|
||||
for pid in winners:
|
||||
winnings[pid] = winnings.get(pid, 0.0) + share
|
||||
pot = 0.0
|
||||
# "carry": potten står urørt og tas med videre til neste hull.
|
||||
return winnings
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from handicap_engine import (
|
|||
handicap_index_from_differentials,
|
||||
low_handicap_index,
|
||||
match_play_strokes,
|
||||
compute_skins,
|
||||
max_hole_score_for_handicap,
|
||||
net_par,
|
||||
round_counts_for_handicap,
|
||||
|
|
@ -502,6 +503,62 @@ def test_course_handicap_9_halves_index_rule_6_1b():
|
|||
assert course_handicap_9(idx, slope9, rating9, par9) == round_half_up(expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skins (ADR-039 Beslutning D)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_skins_outright_win_every_hole():
|
||||
scores = [[("a", 4), ("b", 5), ("c", 6)], [("a", 3), ("b", 3), ("c", 5)]]
|
||||
result = compute_skins(scores, tie_handling="carry")
|
||||
# Hull 1: a vinner (laveste 4) -> 1 skin. Hull 2: a/b uavgjort (3) -> carry.
|
||||
assert result == {"a": 1.0}, result
|
||||
|
||||
|
||||
def test_skins_carry_over_then_win():
|
||||
scores = [
|
||||
[("a", 4), ("b", 4), ("c", 5)], # uavgjort a/b -> carry (pott=1)
|
||||
[("a", 5), ("b", 5), ("c", 4)], # c vinner alene -> tar pott (1+1=2)
|
||||
]
|
||||
result = compute_skins(scores, tie_handling="carry")
|
||||
assert result == {"c": 2.0}, result
|
||||
|
||||
|
||||
def test_skins_three_way_tie_carries_whole_pot():
|
||||
scores = [
|
||||
[("a", 4), ("b", 4), ("c", 4)], # alle like -> carry (pott=1)
|
||||
[("a", 5), ("b", 5), ("c", 3)], # c vinner alene -> tar hele pott (2)
|
||||
]
|
||||
result = compute_skins(scores, tie_handling="carry")
|
||||
assert result == {"c": 2.0}, result
|
||||
|
||||
|
||||
def test_skins_split_tie_instead_of_carry():
|
||||
scores = [[("a", 4), ("b", 4), ("c", 5)]]
|
||||
result = compute_skins(scores, tie_handling="split")
|
||||
assert result == {"a": 0.5, "b": 0.5}, result
|
||||
|
||||
|
||||
def test_skins_split_after_prior_carry_splits_whole_pot():
|
||||
scores = [
|
||||
[("a", 5), ("b", 4), ("c", 6)], # b vinner alene -> tar pott (1)
|
||||
[("a", 4), ("b", 4), ("c", 5)], # a/b uavgjort -> splittes (pott=1 denne runden)
|
||||
]
|
||||
result = compute_skins(scores, tie_handling="split")
|
||||
assert result == {"b": 1.0 + 0.5, "a": 0.5}, result
|
||||
|
||||
|
||||
def test_skins_empty_hole_skipped_not_an_error():
|
||||
scores = [[], [("a", 4), ("b", 5)]]
|
||||
result = compute_skins(scores, tie_handling="carry")
|
||||
assert result == {"a": 1.0}, result
|
||||
|
||||
|
||||
def test_skins_no_winner_if_never_decided():
|
||||
scores = [[("a", 4), ("b", 4)], [("a", 5), ("b", 5)]]
|
||||
result = compute_skins(scores, tie_handling="carry")
|
||||
assert result == {}, result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enkel selvsjekk uten pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue