All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:

Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.

A few real things surfaced and were fixed during testing:

Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
This commit is contained in:
Erol Haagenrud 2026-07-30 14:54:11 +02:00
parent facefd9951
commit 7ae554062c
21 changed files with 2529 additions and 43 deletions

25
041_chapman_format.sql Normal file
View file

@ -0,0 +1,25 @@
-- =====================================================================
-- TeeCup — Chapman/Pinehurst ("Try all"), migrasjon 041. Første av åtte
-- nye turneringsformater denne runden (se plan/ADR for full liste og
-- begrunnelse). Chapman er strukturelt IDENTISK med eksisterende
-- foursome (delt ball, én score per hull per side, CombinedPercentage
-- 50%) -- reglene om hvem som slår hvilket slag (bytt utslag, velg
-- beste 2. slag, spilleren som ikke ble valgt slår 3. slag, deretter
-- alternerer de) er ren gameplay-etikette som foursome heller ikke
-- sporer noe sted -- ingen ny motorlogikk, kun et nytt formatnavn som
-- ALIASES til foursome (se app/handicap.py sin nye
-- ENGINE_FORMAT_ALIASES og rounds.py sin _ROUND_FORMAT_TO_ENGINE_FORMAT).
--
-- To separate CHECK-utvidelser: round.play_format (frittstående runder)
-- OG session.format (org-lagturneringer) -- Chapman er ALDRI aktuelt for
-- den individuelle org-modellen (ADR-037), kun to-siders formater.
-- =====================================================================
\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', 'stableford', 'chapman'));
ALTER TABLE session DROP CONSTRAINT session_format_check;
ALTER TABLE session ADD CONSTRAINT session_format_check
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman'));

View file

@ -0,0 +1,30 @@
-- =====================================================================
-- TeeCup — Københavner/Copenhagen, migrasjon 042. Andre av åtte nye
-- turneringsformater denne runden (se plan/ADR for full liste).
--
-- Flatt felt, nøyaktig 3 spillere, INGEN sider -- passer ikke ADR-011s
-- to-lags-modell (bekreftet av ADR-011s eget 2026-07-19-notat). Hører
-- derfor hjemme i frittstående runder (round.play_format) OG den
-- individuelle org-turnering-modellen (ADR-037, tournament.scoring_
-- method) -- ALDRI org-lagturneringer.
--
-- 6 poeng per hull fordelt etter relativ rangering (se handicap_engine.py
-- sin copenhagen_points_for_hole -- kilde: spilletyper-og-spilleformer-
-- 2023.pdf s.4). Frittstående runder er individuell-ball (som stroke/
-- stableford), beregnes helt ved lesing fra eksisterende round_hole --
-- INGEN nye kolonner der. Den individuelle org-modellen cacher derimot
-- et ferdig utregnet resultat per deltaker (samme mønster som gross_
-- total/net_total/stableford_points i tournament_round_score, migrasjon
-- 040) -- én ny kolonne.
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen'));
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen'));
ALTER TABLE tournament_round_score ADD COLUMN copenhagen_points smallint;

34
043_bbb_round_format.sql Normal file
View file

@ -0,0 +1,34 @@
-- =====================================================================
-- TeeCup — Bingo Bango Bongo (frittstående runder), migrasjon 043.
-- Fjerde av åtte nye turneringsformater denne runden (se plan/ADR for
-- full liste). Flatt felt, INGEN sider, poengbasert. Bingo/bango/bongo
-- er en per-HULL-fakta (hvem vant hver av de tre kategoriene), ikke en
-- per-spiller/side-fakta -- passer derfor IKKE inn i round_hole sin
-- eksisterende XOR-form (round_participant_id ELLER round_side_id) --
-- krever en ny, liten dedikert tabell, én rad per hull for hele runden.
--
-- Alle tre kategoriene registreres MANUELT av den som fører score, samme
-- mønster som "valgt utslag" i scramble/greensome (migrasjon 036) -- INGEN
-- GPS/live-sporing. Sweep-bonus (dobbelt poeng ved rent sveip) er en
-- valgfri organisator-innstilling (bekreftet av bruker), IKKE fast på.
-- Lagvariant bevisst UTENFOR omfang v1 (bekreftet av bruker).
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen', 'bbb'));
ALTER TABLE round ADD COLUMN bbb_sweep_bonus_enabled boolean NOT NULL DEFAULT false;
CREATE TABLE round_bbb_hole (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
bingo_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
bango_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
bongo_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
UNIQUE (round_id, hole_number)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON round_bbb_hole TO teecup_app;

View file

@ -0,0 +1,48 @@
-- =====================================================================
-- TeeCup — Bingo Bango Bongo (org individuell turnering), migrasjon 044.
-- Samme begrunnelse som 043 (frittstående runder) -- se den migrasjonens
-- header. Denne dekker den parallelle, RLS-beskyttede org-modellen
-- (ADR-037): tournament.scoring_method='bingo_bango_bongo' + en ny
-- dedikert tabell tournament_round_bbb_hole (samme composite-FK-/RLS-
-- mønster som resten av migrasjon 040).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo'));
ALTER TABLE tournament ADD COLUMN bbb_sweep_bonus_enabled boolean NOT NULL DEFAULT false;
-- Cachet totalsum per deltaker per runde (samme mønster som gross_total/
-- net_total/stableford_points/copenhagen_points -- SQL summerer bare det
-- cachede ved lesing i leaderboardet, motoren regner på nytt ved hver
-- hull-innsending).
ALTER TABLE tournament_round_score ADD COLUMN bbb_points smallint;
CREATE TABLE tournament_round_bbb_hole (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL,
tournament_round_id uuid NOT NULL,
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
bingo_participant_id uuid,
bango_participant_id uuid,
bongo_participant_id uuid,
FOREIGN KEY (organization_id, tournament_round_id)
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
FOREIGN KEY (organization_id, bingo_participant_id)
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
FOREIGN KEY (organization_id, bango_participant_id)
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
FOREIGN KEY (organization_id, bongo_participant_id)
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
UNIQUE (tournament_round_id, hole_number)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_round_bbb_hole TO teecup_app;
ALTER TABLE tournament_round_bbb_hole ENABLE ROW LEVEL SECURITY;
ALTER TABLE tournament_round_bbb_hole FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON tournament_round_bbb_hole
USING (organization_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);

19
045_flag_round_format.sql Normal file
View file

@ -0,0 +1,19 @@
-- =====================================================================
-- TeeCup — Flaggturnering/Flag tournament (frittstående runder),
-- migrasjon 045. Femte av åtte nye turneringsformater denne runden.
--
-- Flatt felt, individuelt, INGEN sider. Gjenbruker EKSISTERENDE
-- individuell-ball round_hole-lagring og round_participant.course_
-- handicap_snapshot (allerede beregnet ved deltaker-opprettelse for
-- ALLE formater, se _create_participant) -- INGEN nye kolonner. Par +
-- course handicap gir et fast slag-budsjett for runden; flagget plantes
-- der spilleren går tom (handicap_engine.py sin flag_result).
--
-- v1: hull-granularitet (ikke sub-hull/GPS -- bevisst utenfor omfang),
-- ingen fortsettelse på hull 19+.
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag'));

View file

@ -0,0 +1,12 @@
-- =====================================================================
-- TeeCup — Flaggturnering/Flag tournament (org individuell turnering),
-- migrasjon 046. Samme begrunnelse som 045 -- se den migrasjonens
-- header. Gjenbruker eksisterende tournament_round_participant.course_
-- handicap (allerede beregnet av _compute_round_participant_handicap
-- for ALLE scoring_method-verdier) -- INGEN nye kolonner.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo', 'flag'));

View file

@ -0,0 +1,31 @@
-- =====================================================================
-- TeeCup — Shamble, migrasjon 047. Sjette av åtte nye turneringsformater
-- denne runden.
--
-- 2-4-manns lag (FLEKSIBEL størrelse, bekreftet av bruker -- ikke fast
-- som de andre formatene). Lagets hullscore = sum av de N beste
-- individuelle resultatene ("beste N av M", N organisator-konfigurerbart
-- via shamble_best_n).
--
-- Frittstående runder: INGEN round_side-involvering (bekreftet av
-- bruker) -- laget ER runden sitt fulle deltakersett, ingen motstander
-- innad. Flere lag som konkurrerer settes opp som separate runder koblet
-- med det allerede byggede flight_group_id-leaderboardet (2026-07-28).
-- Individuell-ball round_hole (som fourball), bare variabel lagstørrelse
-- i stedet for en fast FORMAT_UNIT_SIZE-verdi.
--
-- Org-lagturnering: HER brukes eksisterende to-siders match-struktur (to
-- Shamble-lag møtes) -- variabel lagstørrelse (2-4) i stedet for fast
-- antall.
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble'));
ALTER TABLE round ADD COLUMN shamble_best_n smallint CHECK (shamble_best_n IS NULL OR shamble_best_n >= 1);
ALTER TABLE session DROP CONSTRAINT session_format_check;
ALTER TABLE session ADD CONSTRAINT session_format_check
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble'));
ALTER TABLE session ADD COLUMN shamble_best_n smallint CHECK (shamble_best_n IS NULL OR shamble_best_n >= 1);

21
048_money_ball_round.sql Normal file
View file

@ -0,0 +1,21 @@
-- =====================================================================
-- TeeCup — Money Ball/Lone Ranger (frittstående runder), migrasjon 048.
-- Sjuende av åtte nye turneringsformater denne runden.
--
-- Nøyaktig 4-manns lag. Samme "ett lag = hele runden, INGEN round_side"
-- -struktur som Shamble (bekreftet av bruker) -- flere lag som
-- konkurrerer settes opp som separate runder + flight_group_id.
--
-- Trenger en FAST spillerrekkefølge (round_participant.lineup_order,
-- 0-3) for å vite hvem som er "money ball" på hvilket hull -- et
-- konsept som ikke fantes noe sted i skjemaet fra før.
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball'));
ALTER TABLE round_participant ADD COLUMN lineup_order smallint CHECK (lineup_order IS NULL OR lineup_order BETWEEN 0 AND 3);
CREATE UNIQUE INDEX round_participant_lineup_order_unique
ON round_participant (round_id, lineup_order) WHERE lineup_order IS NOT NULL;

View file

@ -0,0 +1,17 @@
-- =====================================================================
-- TeeCup — Money Ball/Lone Ranger (org-lagturnering), migrasjon 049.
-- Samme begrunnelse som 048 -- se den migrasjonens header. HER brukes
-- eksisterende to-siders match-struktur (to Money Ball-lag à 4 møtes).
-- lineup_order plasseres på match_participant (ikke team_roster) siden
-- rekkefølgen er meningsfull PER MATCH, samme prinsipp som team_side
-- selv er match-scopet, ikke roster-scopet.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE session DROP CONSTRAINT session_format_check;
ALTER TABLE session ADD CONSTRAINT session_format_check
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble', 'money_ball'));
ALTER TABLE match_participant ADD COLUMN lineup_order smallint CHECK (lineup_order IS NULL OR lineup_order BETWEEN 0 AND 3);
CREATE UNIQUE INDEX match_participant_lineup_order_unique
ON match_participant (match_id, team_side, lineup_order) WHERE lineup_order IS NOT NULL;

View file

@ -0,0 +1,20 @@
-- =====================================================================
-- TeeCup — High-low-high, migrasjon 050. Siste (åttende) av de nye
-- turneringsformatene denne runden.
--
-- 2 lag à 2 spillere -- strukturelt identisk med fourball (individuell
-- ball, round_hole/hole_score keyet per SPILLER, ikke per side). INGEN
-- nye kolonner -- per-hull-rangeringen (høy/lav) og poengtellingen
-- regnes helt ved LESING (handicap_engine.py sin high_low_high_points_
-- for_hole/high_low_high_running_score), ingen cache-kolonne (samme
-- "regn ut ved lesing"-filosofi som Nassau).
-- =====================================================================
\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', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high'));
ALTER TABLE session DROP CONSTRAINT session_format_check;
ALTER TABLE session ADD CONSTRAINT session_format_check
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble', 'money_ball', 'high_low_high'));

View file

@ -5855,6 +5855,58 @@ Ferdig og verifisert:
rent, `/health`/`/dashboard`/scorekort-siden → 200, `teeoff.no`
upåvirket.
- **Åtte nye turneringsformater: BACKEND FERDIG OG SCRATCH-VERIFISERT,
IKKE ENNÅ RULLET UT (2026-07-30):** brukeren ba om å ta fatt på det
lenge noterte "flere turneringsformater"-punktet fra 2026-07-19. Listen
ble utvidet fra fire til åtte (Shamble, Chapman/Pinehurst, Bingo Bango
Bongo, Money Ball/Lone Ranger, Nassau Match Play lagt til; "High-low-
high"/"Try all" presist avklart av bruker med et fullt utregnet
eksempel; "Robbins" droppet). Full plan skrevet og godkjent (plan-
modus) FØR bygging, deretter bygget ETT format om gangen i rekkefølgen
Chapman → Nassau → Københavner → Bingo Bango Bongo → Flaggturnering →
Shamble → Money Ball → High-low-high, motor→migrasjon→API→scratch-
verifisering per format FØR neste startet.
**Arkitektonisk hjem** (bekreftet av bruker "begge, fra start"): flatt-
felt-formater (Københavner/BBB/Flag) → frittstående runder OG den
individuelle org-turnering-modellen (ADR-037), ALDRI org-lagturneringer
(ADR-011s to-lags-modell passer strukturelt ikke). To-siders-formater
(Chapman/Nassau/Shamble/Money Ball/High-low-high) → frittstående runder
OG org-lagturneringer, ALDRI den individuelle org-modellen. Shamble/
Money Ball i frittstående runder: ETT LAG = HELE RUNDENS deltakersett
(bekreftet av bruker, INGEN `round_side`) -- flere lag kobles via
eksisterende `flight_group_id`-leaderboard.
**Ny delt `ENGINE_FORMAT_ALIASES`-mekanisme** i `app/handicap.py` --
Chapman/Shamble/Money Ball/High-low-high aliaserer til
foursome/singles/singles/fourball for HCP-beregning (ingen dupliser
allowance-logikk), løst opp FØRST i `parse_allowance_config`/
`compute_and_store_side_handicaps`/`relative_strokes_for_match`.
**Reelle funn/presiseringer underveis, alle rettet FØR utrulling:**
Nassau kan hindres av org-matchers eksisterende ALREADY_DECIDED-sperre
(ADR-012) hvis overall-matchen avgjøres tidlig -- dokumentert v1-
begrensning, ikke fikset. Københavner-poeng må regnes om for ALLE tre
deltakerne samtidig (eneste scoring_method som bryter "regn om for ÉN
deltaker"-mønsteret). Money Ball-rotasjon er basert på POSISJON i spilt
rekkefølge, ikke rått hullnummer. High-low-high passer ikke inn i den
ternære `HoleResult`-cachen -- egen dedikert leseendepunkt per system
(som Nassau), generisk `status_text` viser en ufarlig statisk "AS"-
plassholder for dette formatet. To reelle "manglende SELECT-kolonne"-
krasj (bbb_sweep_bonus_enabled, shamble_best_n) fanget og rettet under
scratch-testing, før noe nådde en antatt-ferdig tilstand.
**Verifisert grundig:** `handicap_engine.py`-enhetstester 98/98 (opp fra
63 FØR denne runden), full scratch-API-verifisering i begge relevante
systemer per format (isolert `teecup_app_scratch`-rolle + isolert
scratch-MinIO + engangs API-container) -- over 400 sjekker totalt på
tvers av de åtte formatenes testskript, inkl. High-low-high sin
eksakte gjenskaping av brukerens eget håndregnede eksempel ("1-1 etter
hull 1", "2-1 til lag 2"). `test_isolation.sql` 12/12 uendret gjennom
hele runden (migrasjonene 041-050 er rent additive).
**IKKE bygget ennå, bevisst neste steg:** frontend for samtlige åtte
formater (nye format-valg i opprett-runde/opprett-økt, nye
resultatvisninger). **Migrasjoner 041-050 BYGGET OG SCRATCH-VERIFISERT,
IKKE ENNÅ rullet ut mot ekte `teecup_db`** -- venter på eksplisitt
bekreftelse fra bruker. Se FEATURE_BACKLOG.md for full detalj per
format.
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen

View file

@ -495,7 +495,17 @@
progresjons-lag.
### Flere turneringsformater utover Ryder Cup (reist 2026-07-19)
- **Status:** 📋 notert, IKKE designet/bygget — trenger egen ADR-runde senere.
- **Status (2026-07-30): ✅ BACKEND FERDIG for alle åtte formater** (Chapman/
Try-all, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble,
Money Ball/Lone Ranger, High-low-high) — motor, migrasjoner, API-wiring
for frittstående runder OG org-scopede turneringer (lag ELLER individuell,
avhengig av formatets struktur), scratch-verifisert grundig i BEGGE
systemer for hvert format (engine-enhetstester FØRST, deretter full
API-verifisering mot ekte HTTP-endepunkter). **Frontend fortsatt IKKE
bygget** — se eget avsnitt nederst i denne seksjonen for full detalj,
migrasjonsnumre, og kjente v1-avgrensninger per format. "Robbins" ble
bevisst DROPPET av bruker (ikke bekreftet i noen kilde). Se ADR-en i
ARCHITECTURE_DECISIONS.md for arkitekturbeslutningene.
- Brukeren ønsker at TeeCup etter hvert skal støtte flere turneringsformater enn
dagens rene to-lags Ryder Cup-modell (ADR-011). Fire konkrete eksempler gitt,
med ulik arkitektonisk konsekvens (fra «passer nesten inn i dag» til «krever
@ -574,6 +584,123 @@
handicap-/slagfordelingslogikken for disse fire formatene, se CLAUDE.md
sin "Autoritative kilder"-seksjon.
### 2026-07-30: alle åtte formater — BACKEND ferdig, frontend gjenstår
Brukeren utvidet listen fra fire til åtte format denne runden (Shamble,
Chapman/Pinehurst, Bingo Bango Bongo, Money Ball/Lone Ranger, Nassau Match
Play lagt til; «High-low-high»/«Try all» presist avklart med et fullt
utregnet eksempel fra bruker; «Robbins» droppet). Full plan skrevet og
godkjent (plan-modus), deretter bygget ett format om gangen i rekkefølgen
Chapman → Nassau → Københavner → Bingo Bango Bongo → Flaggturnering →
Shamble → Money Ball → High-low-high, motor→migrasjon→API→scratch-
verifisering per format FØR neste startet, per prosjektets etablerte
disiplin.
**Arkitektonisk hjem, bekreftet av bruker FØR bygging («begge, fra
start»):**
- Flatt felt, ingen sider (Københavner, Bingo Bango Bongo, Flaggturnering):
frittstående runder (`round.play_format`) OG den individuelle org-
turnering-modellen (`tournament.scoring_method`, ADR-037) — ALDRI
org-lagturneringer (ADR-011s to-lags-modell passer strukturelt ikke).
- To-siders (Chapman, Nassau, Shamble, Money Ball, High-low-high):
frittstående runder OG org-lagturneringer (`session.format`) — ALDRI den
individuelle org-modellen.
- **Shamble/Money Ball i frittstående runder:** bekreftet av bruker at ETT
LAG = HELE RUNDENS deltakersett, INGEN `round_side`-involvering — flere
lag som konkurrerer settes opp som separate runder koblet med det
allerede byggede `flight_group_id`-leaderboardet (2026-07-28).
**Per format, kort:**
1. **Chapman/Pinehurst ("Try all")** — INGEN ny motor. Ny
`ENGINE_FORMAT_ALIASES`-mekanisme i `app/handicap.py`
(`{"chapman": "foursome"}`, senere gjenbrukt for shamble/money_ball/
high_low_high) — strukturelt identisk med foursome. Migrasjon 041.
2. **Nassau Match Play** — INGEN ny motor, INGEN skjemaendring. Tre
parallelle vinduer (hull 1-9/10-18/1-18) av EKSISTERENDE hull-for-hull-
data, hver kjørt gjennom den allerede eksisterende `compute_match_
state`. Nye leseendepunkter (`/rounds/{id}/format-result/nassau`,
`/orgs/.../matches/{id}/nassau`). **Reelt funn under scratch-testing:**
org-matcher låser seg (409 ALREADY_DECIDED) straks OVERALL 18-hulls-
matchen er avgjort (ADR-012) — en runaway-margin kan derfor hindre at
alle 18 hull noensinne blir registrert, som igjen gir ufullstendige
front9/back9-vinduer. Ikke fikset (ville krevd å løsne en etablert
sikkerhetssperre) — dokumentert som en kjent v1-begrensning.
3. **Københavner** — ny motor (`copenhagen_points_for_hole`/`compute_
copenhagen_detail`, samme `(deltaker_id, verdi)`-per-hull-form som
`compute_skins_detail`). Migrasjon 042 (`round.play_format` +
`tournament.scoring_method` + `tournament_round_score.copenhagen_
points`). Alltid NETTO (individuell course handicap-allokering, ikke
match-play-relativ). **Reelt funn:** Københavner-poeng avhenger av ALLE
tre deltakerne samtidig — org-siden måtte derfor bryte det etablerte
"regn om for ÉN deltaker om gangen"-mønsteret (`_recompute_copenhagen_
points` regner om for alle tre ved hver hull-innsending).
4. **Bingo Bango Bongo** — ny motor (`bbb_points_for_hole`/`compute_bbb`).
Ny, liten dedikert tabell BEGGE steder (`round_bbb_hole`/`tournament_
round_bbb_hole`) siden bingo/bango/bongo er en per-HULL-fakta, ikke
per-spiller/side — passer ikke i `round_hole`s eksisterende XOR-form.
Migrasjon 043/044. Sveip-bonus (dobbelt poeng) er en valgfri
organisator-innstilling (`bbb_sweep_bonus_enabled`), IKKE fast på.
Lagvariant bevisst utenfor omfang v1.
5. **Flaggturnering** — ny motor (`flag_result`/`FlagResult`). INGEN nye
kolonner — gjenbruker eksisterende individuell-ball-lagring og
`course_handicap_snapshot`/`course_handicap` (allerede beregnet for
ALLE formater ved deltaker-opprettelse). Migrasjon 045/046 er REN
CHECK-utvidelse. v1: hull-granularitet, ingen fortsettelse på hull 19+.
Org-siden fikk et eget, PER-RUNDE (ikke summert-på-tvers-av-turneringen)
leseendepunkt (`/rounds/{id}/flag-result`), siden Flag strukturelt er en
enkelt-rundes-konkurranse, ikke et akkumulerbart poengtall.
6. **Shamble** — ny motor (`shamble_hole_score`, "beste N av M").
Migrasjon 047 (`shamble_best_n` på BEGGE `round`/`session`). Org-siden:
variabel lagstørrelse (2-4) håndteres via en NY, dedikert
`_compute_shamble_hole_results` i `scoring.py` (rå brutto, IKKE via den
generiske `_side_net`, som forutsetter en FAST enhetsstørrelse per
format) — kun et TAK (maks 4) håndheves ved tilføyelse, en tilsvarende
"minst 2"-fullstendighets-gate finnes bevisst IKKE for org-lagturneringer
(samme begrensning som fourball allerede har).
7. **Money Ball/Lone Ranger** — ny motor (`money_ball_hole_score`, fast
4-manns rotasjon). Ny `lineup_order`-kolonne BEGGE steder
(`round_participant`/`match_participant`, 0-3, unik per runde/side).
Migrasjon 048/049. **Viktig presisering:** rotasjonen er basert på
POSISJON i den faktisk spilte rekkefølgen (1. spilte hull = rotasjons-
indeks 0), IKKE rått hullnummer — riktig også for en runde/økt som ikke
starter på hull 1.
8. **High-low-high** — den mest nyskapende motoren
(`high_low_high_points_for_hole`/`high_low_high_running_score`) —
**verifisert tall for tall mot brukerens eget fullt utregnede eksempel**
(«1-1 etter hull 1», «2-1 til lag 2 før hull 3») i BÅDE enhetstestene og
scratch-API-testene. Nøkkelinnsikt fra brukerens presisering: et
uavgjort del-oppgjør («å dele et hull») gir NULL poeng til begge sider,
IKKE en 0,5/0,5-splitt. Migrasjon 050 er ren CHECK-utvidelse (individuell
ball, gjenbruker fourballs eksisterende lagring). Passer IKKE inn i den
ternære `HoleResult`-cachen (`match.status_text`/`points_side_a/b`) —
egen, dedikert leseendepunkt (`/orgs/.../matches/{id}/high-low-high`)
for org-siden, samme "regn ut ved lesing"-filosofi som Nassau. **Kjent,
ufarlig v1-kvirk:** den GENERISKE `match.status_text` viser en statisk
"AS"-plassholder for High-low-high-matcher (siden `_compute_hole_
results` bevisst returnerer tom liste for dette formatet) — ikke en
krasj, bare ikke spesielt informativt der; den ekte stillingen kommer
fra det dedikerte endepunktet.
**Verifisering, alle åtte formater:** isolerte `handicap_engine.py`-
enhetstester FØRST (98 totalt etter denne runden, opp fra 63), deretter
full scratch-API-verifisering i BEGGE relevante systemer per format
(isolert `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs
API-container, samme mønster som resten av prosjektet) — over 400
automatiserte sjekker totalt på tvers av de åtte formatenes test-skript,
pluss `test_isolation.sql` 12/12 uendret gjennom hele runden (migrasjonene
er rent additive).
**IKKE bygget i denne runden, bevisst neste steg:** frontend for samtlige
åtte formater — ingen ny UI for formatvalg (opprett-runde/opprett-økt-
skjemaene), ingen nye resultatvisninger (Nassau-vinduer, Københavner-/BBB-
poengtavler, Flag-nedtelling, Shamble/Money Ball-lagvisning, High-low-
high-stilling). 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/039: motor/skjema/API FØR frontend).
**Migrasjoner 041-050 er BYGGET OG SCRATCH-VERIFISERT, IKKE ENNÅ rullet ut
mot ekte `teecup_db`** — venter på eksplisitt bekreftelse fra bruker før
noen migrasjon kjøres mot produksjon, per CLAUDE.md sin ufravikelige regel.
### Utvidelse 2026-07-26: individuelle turneringer, flerrunde-turneringer, og Order of Merit
Reist av brukeren som svar på et spørsmål om et individuelt

View file

@ -42,6 +42,42 @@ FORMAT_UNIT_SIZE: dict[str, int] = {
"scramble_4": 4,
}
# Nye formater denne runden (2026-07-30) som er strukturelt IDENTISKE med et
# eksisterende format -- ingen egen HCP-/allowance-logikk, bare et annet
# selvdeklarert navn. Løses opp HELT FØRST i parse_allowance_config/
# compute_and_store_side_handicaps/relative_strokes_for_match, slik at resten
# av disse funksjonene (og SIDE_IS_UNIT/FORMAT_UNIT_SIZE/Format-enumet)
# aldri ser aliasnavnet. Chapman/Pinehurst ("Try all", bekreftet av bruker):
# delt ball, én score per hull per side, CombinedPercentage 50% -- identisk
# med foursome, kun regler for HVEM som slår hvilket slag (ikke sporet i
# skjemaet, verken her eller for foursome).
ENGINE_FORMAT_ALIASES: dict[str, str] = {
"chapman": "foursome",
# Shamble (2026-07-30) -- variabel lagstørrelse (2-4), enheten er
# SPILLEREN (som fourball/singles), ikke siden -- "singles" sin
# PerPlayerPercentage(1.00) er telle-agnostisk (regner ett tall per
# spiller uavhengig av hvor mange andre er på laget), i motsetning til
# foursome/greensome/scramble sine faste antalls-strategier.
# Kildens hullscore-regel (beste N av M) bruker rå brutto, ikke HCP-
# justert netto -- course_handicap/playing_handicap beregnes og lagres
# likevel her (for visning/et fremtidig steg), men brukes IKKE av
# selve hullscore-utregningen (se scoring.py sin _side_net shamble-gren).
"shamble": "singles",
# Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag, enheten er
# SPILLEREN (som Shamble/fourball), ikke siden. Rå brutto brukes for
# selve hullscoren (se scoring.py sin _compute_money_ball_hole_results),
# course_handicap/playing_handicap beregnes og lagres likevel for
# visning (samme begrunnelse som Shamble).
"money_ball": "singles",
# High-low-high (2026-07-30) -- 2 lag à 2, individuell ball. course_
# handicap/playing_handicap beregnes som fourball (samme "alle fire
# samlet i én match_play_strokes-pool"-regel, R&A) -- selve poeng-
# rangeringen (high/low per hull) er en HELT NY, egen beregning (se
# app/routers/rounds.py sin _build_high_low_high_result), ikke del av
# denne aliaseringen.
"high_low_high": "fourball",
}
@dataclass(frozen=True)
class AllowanceConfig:
@ -68,6 +104,7 @@ def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy:
def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig:
"""ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard."""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
override = allowance_override or {}
return AllowanceConfig(
use_handicap=override.get("use_handicap", True),
@ -115,6 +152,7 @@ async def compute_and_store_side_handicaps(
den validerer ikke selv (samme mønster som for manglende
handicap_index_snapshot).
"""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
participants = await conn.fetch(
"""
SELECT mp.id::text AS id,
@ -196,6 +234,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å".
"""
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
if format_ in SIDE_IS_UNIT:
rows = await conn.fetch(
"""

View file

@ -25,10 +25,18 @@ heller aldri har vært captain-/admin-gatet. Kun VALGET AV HVEM SOM SPILLER
og selve SCORINGEN er gatet strengere noe sted i appen, aldri det generelle
oppsettet.
Bevisst UTENFOR omfang (se ADR-037): Københavner/High-low-high/Robbins/
Try all/Flaggturnering (krever paring TVERS av spillere per hull, ikke
bare uavhengige rene slagspill-/Stableford-poeng) og Order of Merit
(sesong-sammenlagt tvers av FLERE turneringer).
Københavner (2026-07-30): scoring_method='copenhagen' -- flatt felt av
NØYAKTIG 3 spillere, 6 poeng delt per hull etter relativ NETTO-rangering
(handicap_engine.py sin compute_copenhagen_detail). Eneste scoring_method
her som krever paring TVERS av spillere per hull -- _recompute_
copenhagen_points regner derfor om for ALLE tre samtidig (kalt fra
update_hole), ikke ett-om-gangen slik gross/net/stableford ellers gjøres.
Bevisst UTENFOR omfang (se ADR-037): High-low-high/Robbins/Try all/
Flaggturnering (High-low-high/Robbins/Try all krever to SIDER, ikke et
flatt felt -- hører hjemme i org-lagturneringer i stedet, se matches.py/
scoring.py) og Order of Merit (sesong-sammenlagt tvers av FLERE
turneringer).
"""
from __future__ import annotations
@ -38,7 +46,11 @@ from pydantic import BaseModel, Field
from handicap_engine import (
allocate_over_played_holes,
bbb_points_for_hole,
compute_bbb,
compute_copenhagen_detail,
course_handicap_raw,
flag_result,
round_half_up,
stableford_total,
stroke_play_gross_total,
@ -53,7 +65,7 @@ from .scoring import played_hole_numbers
router = APIRouter()
_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford"}
_SCORING_METHODS_REQUIRING_HANDICAP = {"stroke_net", "stableford", "copenhagen", "flag"}
# =====================================================================
@ -567,6 +579,195 @@ async def _recompute_round_score(conn, round_id: str, round_participant_id: str,
)
async def _recompute_copenhagen_points(conn, round_id: str, organization_id: str) -> None:
"""Københavner (2026-07-30): 6 poeng deles per hull mellom NØYAKTIG 3
deltakere basert relativ NETTO-rangering -- kan derfor IKKE regnes
per deltaker isolert slik gross/net/stableford ellers gjøres (se
modulens docstring, "bevisst utenfor omfang" er innhentet for dette
ene formatet). Regner om for ALLE deltakerne i runden samtidig, kalt
etter HVER hull-innsending når scoring_method='copenhagen' -- ETTER at
_recompute_round_score allerede har kjørt for alle tre (sikrer at
tournament_round_score-raden finnes for alle, ikke bare den som nettopp
scoret)."""
participants = await conn.fetch(
"""
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
trp.playing_handicap
FROM tournament_round_participant trp
WHERE trp.tournament_round_id = $1
""",
round_id,
)
if len(participants) != 3:
return # Ikke komplett ennå -- copenhagen_points_for_hole krever nøyaktig 3
course_id = await conn.fetchval("SELECT course_id::text FROM tournament_round WHERE id = $1", round_id)
all_18 = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number", course_id
)
all_18_si = [h["stroke_index"] for h in all_18]
net_by_hole: dict[int, list[tuple[str, int]]] = {}
for p in participants:
if p["playing_handicap"] is None:
continue
holes = await conn.fetch(
"SELECT hole_number, gross_strokes FROM tournament_round_hole "
"WHERE tournament_round_participant_id = $1 AND gross_strokes IS NOT NULL "
"ORDER BY hole_number",
p["id"],
)
if not holes:
continue
played_numbers = [h["hole_number"] for h in holes]
allocation = allocate_over_played_holes(p["playing_handicap"], all_18_si, played_numbers)
for h, received in zip(holes, allocation):
net = h["gross_strokes"] - received
net_by_hole.setdefault(h["hole_number"], []).append((p["tournament_participant_id"], net))
scores_by_hole = [net_by_hole.get(h["hole_number"], []) for h in all_18]
totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False)
for p in participants:
await conn.execute(
"""
INSERT INTO tournament_round_score
(organization_id, tournament_round_id, tournament_participant_id, copenhagen_points, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
copenhagen_points = $4, updated_at = now()
""",
organization_id,
round_id,
p["tournament_participant_id"],
totals.get(p["tournament_participant_id"]),
)
async def _recompute_bbb_points(conn, round_id: str, organization_id: str) -> None:
"""Bingo Bango Bongo (2026-07-30): poeng er en per-HULL-fakta på tvers
av ALLE deltakerne i runden, ikke per deltaker isolert -- regner om for
alle samtidig, kalt etter HVER bbb-hull-registrering. Krever INGEN
handicap (ulikt Københavner) -- rene observasjons-poeng."""
participants = await conn.fetch(
"SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id "
"FROM tournament_round_participant trp WHERE trp.tournament_round_id = $1",
round_id,
)
if not participants:
return
tournament = await conn.fetchrow(
"SELECT t.bbb_sweep_bonus_enabled FROM tournament_round tr "
"JOIN tournament t ON t.id = tr.tournament_id WHERE tr.id = $1",
round_id,
)
rows = await conn.fetch(
"""
SELECT hole_number, bingo_participant_id::text AS bingo_participant_id,
bango_participant_id::text AS bango_participant_id,
bongo_participant_id::text AS bongo_participant_id
FROM tournament_round_bbb_hole WHERE tournament_round_id = $1 ORDER BY hole_number
""",
round_id,
)
hole_log = [(r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in rows]
totals = compute_bbb(hole_log, tournament["bbb_sweep_bonus_enabled"])
for p in participants:
await conn.execute(
"""
INSERT INTO tournament_round_score
(organization_id, tournament_round_id, tournament_participant_id, bbb_points, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (tournament_round_id, tournament_participant_id) DO UPDATE SET
bbb_points = $4, updated_at = now()
""",
organization_id,
round_id,
p["tournament_participant_id"],
totals.get(p["tournament_participant_id"]),
)
class BBBHoleUpdate(BaseModel):
bingo_participant_id: str | None = None
bango_participant_id: str | None = None
bongo_participant_id: str | None = None
class BBBHoleOut(BaseModel):
hole_number: int
bingo_participant_id: str | None
bango_participant_id: str | None
bongo_participant_id: str | None
@router.patch(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/bbb/{hole_number}",
response_model=BBBHoleOut,
)
async def update_bbb_hole(
tournament_id: str,
round_id: str,
hole_number: int,
body: BBBHoleUpdate,
organization_id: str = Depends(get_authorized_org),
) -> BBBHoleOut:
"""Runde-/deltaker-oppsett-nivå autorisasjon (get_authorized_org, org-
medlem), samme presedens som resten av dette modulens oppsett -- ikke
strammet inn til "kun deltakeren selv" slik update_hole er, siden
bbb-registrering er en OBSERVASJON om alle deltakerne (hvem som var
først green osv.), ikke én persons egen score."""
async with org_connection(organization_id) as conn, translate_db_errors():
round_row = await conn.fetchrow(
"""
SELECT tr.hole_config::text AS hole_config, t.scoring_method, t.id::text AS tournament_id
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
WHERE tr.id = $1
""",
round_id,
)
if round_row is None or round_row["tournament_id"] != tournament_id:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if round_row["scoring_method"] != "bingo_bango_bongo":
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Bingo Bango Bongo.")
if hole_number not in played_hole_numbers(round_row["hole_config"]):
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
if pid is None:
continue
belongs = await conn.fetchval(
"SELECT 1 FROM tournament_participant WHERE id = $1 AND tournament_id = $2",
pid,
tournament_id,
)
if not belongs:
raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne turneringen.")
row = await conn.fetchrow(
"""
INSERT INTO tournament_round_bbb_hole
(organization_id, tournament_round_id, hole_number,
bingo_participant_id, bango_participant_id, bongo_participant_id)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (tournament_round_id, hole_number) DO UPDATE SET
bingo_participant_id = $4, bango_participant_id = $5, bongo_participant_id = $6
RETURNING hole_number, bingo_participant_id::text AS bingo_participant_id,
bango_participant_id::text AS bango_participant_id,
bongo_participant_id::text AS bongo_participant_id
""",
organization_id,
round_id,
hole_number,
body.bingo_participant_id,
body.bango_participant_id,
body.bongo_participant_id,
)
await _recompute_bbb_points(conn, round_id, organization_id)
return BBBHoleOut(**dict(row))
@router.patch(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}"
"/participants/{round_participant_id}/holes/{hole_number}",
@ -583,7 +784,11 @@ async def update_hole(
) -> RoundHoleOut:
async with org_connection(organization_id) as conn, translate_db_errors():
round_row = await conn.fetchrow(
"SELECT course_id::text AS course_id, hole_config::text AS hole_config FROM tournament_round WHERE id = $1",
"""
SELECT tr.course_id::text AS course_id, tr.hole_config::text AS hole_config, t.scoring_method
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
WHERE tr.id = $1
""",
round_id,
)
if round_row is None:
@ -636,6 +841,19 @@ async def update_hole(
body.gross_strokes,
)
await _recompute_round_score(conn, round_id, round_participant_id, organization_id)
if round_row["scoring_method"] == "copenhagen":
# Kobenhavner-poeng avhenger av ALLE tre deltakernes netto på
# hvert hull -- regn om for de to andre ogsa (sikrer at deres
# tournament_round_score-rad finnes) FOR selve poeng-fordelingen.
other_ids = await conn.fetch(
"SELECT id::text AS id FROM tournament_round_participant "
"WHERE tournament_round_id = $1 AND id != $2",
round_id,
round_participant_id,
)
for other in other_ids:
await _recompute_round_score(conn, round_id, other["id"], organization_id)
await _recompute_copenhagen_points(conn, round_id, organization_id)
strokes_received = None
playing_handicap = await conn.fetchval(
@ -724,6 +942,8 @@ class LeaderboardEntry(BaseModel):
gross_total: int | None
net_total: int | None
stableford_total: int | None
copenhagen_total: int | None
bbb_total: int | None
@router.get(
@ -753,7 +973,9 @@ async def individual_leaderboard(
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
SUM(trs.gross_total)::int AS gross_total,
SUM(trs.net_total)::int AS net_total,
SUM(trs.stableford_points)::int AS stableford_total
SUM(trs.stableford_points)::int AS stableford_total,
SUM(trs.copenhagen_points)::int AS copenhagen_total,
SUM(trs.bbb_points)::int AS bbb_total
FROM tournament_participant tp
JOIN player p ON p.id = tp.player_id
LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id
@ -769,6 +991,101 @@ async def individual_leaderboard(
entries.sort(key=lambda e: (e.stableford_total is None, -(e.stableford_total or 0)))
elif method == "stroke_net":
entries.sort(key=lambda e: (e.net_total is None, e.net_total or 0))
elif method == "copenhagen":
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))
elif method == "bingo_bango_bongo":
entries.sort(key=lambda e: (e.bbb_total is None, -(e.bbb_total or 0)))
else:
entries.sort(key=lambda e: (e.gross_total is None, e.gross_total or 0))
return entries
# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30) -- egen, PER-RUNDE visning
# (ikke summert på tvers av turneringen slik individual_leaderboard gjør
# for de andre scoring_method-ene) -- Flag er strukturelt en
# enkelt-rundes-konkurranse ("hvem kommer lengst DENNE runden"), ikke et
# akkumulerbart poeng-/slagtall på tvers av flere runder.
# ---------------------------------------------------------------------------
class FlagParticipantResult(BaseModel):
tournament_participant_id: str
player_name: str
holes_completed: int
ran_out: bool
strokes_remaining: int
@router.get(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/flag-result",
response_model=list[FlagParticipantResult],
)
async def flag_round_result(
tournament_id: str,
round_id: str,
organization_id: str = Depends(get_authorized_org),
) -> list[FlagParticipantResult]:
async with org_connection(organization_id) as conn:
round_row = await conn.fetchrow(
"""
SELECT tr.id::text AS id, tr.hole_config::text AS hole_config, t.scoring_method
FROM tournament_round tr JOIN tournament t ON t.id = tr.tournament_id
WHERE tr.id = $1 AND tr.tournament_id = $2
""",
round_id,
tournament_id,
)
if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
if round_row["scoring_method"] != "flag":
raise app_error(400, "VALIDATION_FAILED", "Denne turneringen bruker ikke Flaggturnering.")
play_order = played_hole_numbers(round_row["hole_config"])
participants = await conn.fetch(
"""
SELECT trp.id::text AS id, trp.course_handicap, tp.id::text AS tournament_participant_id,
p.display_name AS player_name
FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
JOIN player p ON p.id = tp.player_id
WHERE trp.tournament_round_id = $1
""",
round_id,
)
results: list[FlagParticipantResult] = []
for p in participants:
if p["course_handicap"] is None:
continue
hole_rows = await conn.fetch(
"""
SELECT h.hole_number, h.par, trh.gross_strokes
FROM hole h
LEFT JOIN tournament_round_hole trh
ON trh.tournament_round_participant_id = $1 AND trh.hole_number = h.hole_number
WHERE h.course_id = (SELECT course_id FROM tournament_round WHERE id = $2)
""",
p["id"],
round_id,
)
by_hole = {r["hole_number"]: r for r in hole_rows}
total_par = sum(by_hole[h]["par"] for h in play_order if h in by_hole)
gross_prefix = []
for h in play_order:
row = by_hole.get(h)
if row is None or row["gross_strokes"] is None:
break
gross_prefix.append(row["gross_strokes"])
budget = total_par + p["course_handicap"]
fr = flag_result(gross_prefix, budget)
results.append(
FlagParticipantResult(
tournament_participant_id=p["tournament_participant_id"],
player_name=p["player_name"],
holes_completed=fr.holes_completed,
ran_out=fr.ran_out,
strokes_remaining=fr.strokes_remaining,
)
)
results.sort(key=lambda r: -r.holes_completed)
return results

View file

@ -56,6 +56,8 @@ class MatchParticipantOut(BaseModel):
tee_id: str
tee_name: str
playing_handicap: int | None = None
# Money Ball (2026-07-30) -- 0-3, null for alle andre formater.
lineup_order: int | None = None
class MatchCreate(BaseModel):
@ -173,7 +175,7 @@ async def fetch_matches(
"""
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
mp.team_roster_id::text, p.display_name AS player_name,
mp.tee_id::text, tee.name AS tee_name, mp.playing_handicap
mp.tee_id::text, tee.name AS tee_name, mp.playing_handicap, mp.lineup_order
FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
@ -197,6 +199,7 @@ async def fetch_matches(
tee_id=r["tee_id"],
tee_name=r["tee_name"],
playing_handicap=r["playing_handicap"],
lineup_order=r["lineup_order"],
)
)
@ -247,6 +250,9 @@ class ParticipantCreate(BaseModel):
team_side: str = Field(pattern="^[ab]$")
team_roster_id: str
tee_id: str
# Money Ball (2026-07-30) -- KUN meningsfullt for session.format=
# 'money_ball'. 0-3, unik per (match, side).
lineup_order: int | None = Field(default=None, ge=0, le=3)
@router.post(
@ -303,6 +309,45 @@ async def add_participant(
403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette."
)
# Shamble (2026-07-30): variabel lagstørrelse (2-4), IKKE en fast
# FORMAT_UNIT_SIZE -- compute_and_store_side_handicaps sin vanlige
# SIDE_IS_UNIT-kapasitetssjekk gjelder ikke her (Shamble aliaser til
# "singles", individuell enhet, telle-agnostisk). Håndhever derfor
# kun et TAK her (min-2-sjekk finnes bevisst ikke ennå for org-
# lagturneringer -- ingen tilsvarende "er oppsettet komplett"-gate
# finnes for individuelle formater der i dag, samme som fourball).
if session["format"] == "shamble":
side_count = await conn.fetchval(
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
match_id,
body.team_side,
)
if side_count >= 4:
raise app_error(409, "LIMIT_REACHED", "Et Shamble-lag kan maks ha 4 spillere.")
# Money Ball (2026-07-30): FAST 4 -- ikke fleksibel som Shamble.
# lineup_order (0-3) er PÅKREVD her (avgjør rotasjonen), unik per
# (match, side) -- håndhevet eksplisitt FØR innsetting, ikke kun
# via DB-constrainten, for en tydelig feilmelding.
if session["format"] == "money_ball":
side_count = await conn.fetchval(
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
match_id,
body.team_side,
)
if side_count >= 4:
raise app_error(409, "LIMIT_REACHED", "Et Money Ball-lag kan maks ha 4 spillere.")
if body.lineup_order is None:
raise app_error(400, "VALIDATION_FAILED", "lineup_order er påkrevd for Money Ball.")
taken = await conn.fetchval(
"SELECT 1 FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side AND lineup_order = $3",
match_id,
body.team_side,
body.lineup_order,
)
if taken:
raise app_error(409, "DUPLICATE", "En annen spiller på laget har allerede denne plassen i rekkefølgen.")
locked = await locked_team_ids(conn, match["session_id"])
if expected_team_id in locked:
raise app_error(
@ -370,13 +415,13 @@ async def add_participant(
"""
WITH inserted AS (
INSERT INTO match_participant
(organization_id, match_id, team_side, team_roster_id, tee_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, team_side, team_roster_id, tee_id
(organization_id, match_id, team_side, team_roster_id, tee_id, lineup_order)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_side, team_roster_id, tee_id, lineup_order
)
SELECT inserted.id::text, inserted.team_side::text,
inserted.team_roster_id::text, p.display_name AS player_name,
inserted.tee_id::text, tee.name AS tee_name
inserted.tee_id::text, tee.name AS tee_name, inserted.lineup_order
FROM inserted
JOIN team_roster tr ON tr.id = inserted.team_roster_id
JOIN player p ON p.id = tr.player_id
@ -387,6 +432,7 @@ async def add_participant(
body.team_side,
body.team_roster_id,
body.tee_id,
body.lineup_order,
)
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er

File diff suppressed because it is too large Load diff

View file

@ -44,7 +44,16 @@ import json
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state
from handicap_engine import (
HoleResult,
allocate_over_played_holes,
compute_match_state,
high_low_high_points_for_hole,
high_low_high_running_score,
money_ball_hole_score,
shamble_hole_score,
stableford_points_for_hole,
)
from ..auth import CurrentUser, get_current_user
from ..db import org_connection
@ -60,7 +69,7 @@ from ..team_authz import (
router = APIRouter()
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
_INDIVIDUAL_FORMATS = {"singles", "fourball", "shamble", "money_ball", "high_low_high"}
def played_hole_numbers(hole_config: str) -> list[int]:
@ -97,6 +106,105 @@ def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int
return min(nets)
async def _compute_shamble_hole_results(conn, match_id: str, match, played: list[int]) -> list[HoleResult]:
"""Shamble (2026-07-30): lagets hullscore = sum av de `session.
shamble_best_n` laveste INDIVIDUELLE brutto-scorene blant sidens
spillere -- brutto, INGEN handicap-justering (kilden nevner ingen
egen allowance for selve hullscoren, kun for hvem som spiller sammen).
Krever at BEGGE sider har akkurat like mange registrerte spillere som
faktisk står laget (variabel 2-4, satt av organisator) -- egen,
selvstendig sti, IKKE _side_net (som forutsetter en FAST enhetsstørrelse
per format)."""
best_n = match["shamble_best_n"]
if best_n is None:
return []
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], list[int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), []).append(r["gross_strokes"])
side_size_rows = await conn.fetch(
"SELECT team_side::text AS team_side, count(*) AS n FROM match_participant "
"WHERE match_id = $1 GROUP BY team_side",
match_id,
)
expected_by_side = {r["team_side"]: r["n"] for r in side_size_rows}
results = []
for h in played:
scores_a = by_hole_side.get((h, "a"), [])
scores_b = by_hole_side.get((h, "b"), [])
if len(scores_a) != expected_by_side.get("a", -1) or len(scores_b) != expected_by_side.get("b", -1):
break
if not (1 <= best_n <= len(scores_a)) or not (1 <= best_n <= len(scores_b)):
break
score_a = shamble_hole_score(scores_a, best_n)
score_b = shamble_hole_score(scores_b, best_n)
if score_a < score_b:
results.append(HoleResult.SIDE_A)
elif score_b < score_a:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_money_ball_hole_results(conn, match_id: str, played: list[int]) -> list[HoleResult]:
"""Money Ball/Lone Ranger (2026-07-30): lagets hullscore = money-ball-
spillerens brutto + laveste av de tre andre -- brutto, INGEN
handicap-justering (samme begrunnelse som Shamble). Rotasjonen er
basert POSISJON i den spilte rekkefølgen (1. spilte hull =
rotasjonsindeks 0), ikke rått hullnummer."""
score_rows = await conn.fetch(
"SELECT hole_number, team_side::text AS team_side, match_participant_id::text AS match_participant_id, "
"gross_strokes FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
by_hole_side: dict[tuple[int, str], dict[str, int]] = {}
for r in score_rows:
by_hole_side.setdefault((r["hole_number"], r["team_side"]), {})[r["match_participant_id"]] = r["gross_strokes"]
lineup_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side, lineup_order "
"FROM match_participant WHERE match_id = $1",
match_id,
)
ordered_by_side: dict[str, list[str]] = {"a": [None] * 4, "b": [None] * 4}
for r in lineup_rows:
if r["team_side"] in ordered_by_side and r["lineup_order"] is not None:
ordered_by_side[r["team_side"]][r["lineup_order"]] = r["id"]
results = []
for position, h in enumerate(played, start=1):
side_scores: dict[str, int] = {}
ok = True
for side in ("a", "b"):
order = ordered_by_side[side]
if any(pid is None for pid in order):
ok = False
break
hole_scores = by_hole_side.get((h, side), {})
if len(hole_scores) != 4 or any(pid not in hole_scores for pid in order):
ok = False
break
scores_in_order = [hole_scores[pid] for pid in order]
side_scores[side] = money_ball_hole_score(scores_in_order, position)
if not ok:
break
if side_scores["a"] < side_scores["b"]:
results.append(HoleResult.SIDE_A)
elif side_scores["b"] < side_scores["a"]:
results.append(HoleResult.SIDE_B)
else:
results.append(HoleResult.HALVED)
return results
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
played = played_hole_numbers(match["hole_config"])
@ -119,6 +227,24 @@ async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
)
return results
if match["format"] == "shamble":
return await _compute_shamble_hole_results(conn, match_id, match, played)
if match["format"] == "money_ball":
return await _compute_money_ball_hole_results(conn, match_id, played)
if match["format"] == "high_low_high":
# Passer IKKE inn i den ternære HoleResult-modellen under (som
# forutsetter ÉN sammenlignbar verdi per side per hull) -- High-
# low-high trenger BEGGE spilleres individuelle Stableford-poeng
# for selve høy/lav-rangeringen, og literal poengsum (0/1/2 per
# hull), ikke bare hvem som vant. Egen, dedikert GET-endepunkt
# (/high-low-high) dekker den faktiske stillingen -- match sin
# vanlige status_text/leading_side forblir bevisst null for dette
# formatet i v1 (samme "regn ut ved lesing i et eget endepunkt"-
# mønster som Nassau, ikke tvunget inn i denne cachen).
return []
# 'stroke'-modus
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
@ -180,7 +306,7 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
s.hole_config::text AS hole_config, s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override,
s.points_per_match::float AS points_per_match,
s.tournament_id::text AS tournament_id
s.tournament_id::text AS tournament_id, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
FOR UPDATE OF m
@ -606,7 +732,8 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
SELECT m.id::text, m.status_text, m.points_side_a::float AS points_side_a,
m.points_side_b::float AS points_side_b,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override,
s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
@ -687,3 +814,199 @@ async def get_scorecard(
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
return await fetch_scorecard(conn, match_id)
# ---------------------------------------------------------------------------
# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Tre
# parallelle vinduer (hull 1-9, 10-18, 1-18) av EKSISTERENDE hull-for-hull-
# data (_compute_hole_results, allerede posisjonert 1:1 mot hullnummer for
# hole_config='full_18' -- se played_hole_numbers), hver kjørt gjennom den
# allerede eksisterende compute_match_state. Samme "regn ut ved lesing,
# aldri cache"-filosofi som resten av formatresultat-endepunktene.
# ---------------------------------------------------------------------------
class NassauWindowOut(BaseModel):
lead: int
holes_played: int
holes_remaining: int
is_closed: bool
is_dormie: bool
status_text: str
class NassauResultOut(BaseModel):
ready: bool
front9: NassauWindowOut | None = None
back9: NassauWindowOut | None = None
overall: NassauWindowOut | None = None
def _nassau_window(hole_results: list[HoleResult], total_holes: int) -> NassauWindowOut:
state = compute_match_state(hole_results, total_holes=total_holes)
return NassauWindowOut(
lead=state.lead,
holes_played=state.holes_played,
holes_remaining=state.holes_remaining,
is_closed=state.is_closed,
is_dormie=state.is_dormie,
status_text=state.describe(),
)
@router.get(
"/orgs/{organization_id}/matches/{match_id}/nassau",
response_model=NassauResultOut,
)
async def get_nassau_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> NassauResultOut:
"""Samme autorisasjon som get_scorecard over -- org-medlem ELLER
deltaker i turneringen matchen hører til."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text AS id, s.tournament_id::text AS tournament_id,
s.format, s.scoring_mode, s.hole_config::text AS hole_config,
s.course_id::text AS course_id,
s.allowance_override::text AS allowance_override, s.shamble_best_n
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["hole_config"] != "full_18":
raise app_error(400, "VALIDATION_FAILED", "Nassau krever 18 hull.")
hole_results = await _compute_hole_results(conn, match_id, match)
if not hole_results:
return NassauResultOut(ready=False)
return NassauResultOut(
ready=True,
front9=_nassau_window(hole_results[0:9], total_holes=9),
back9=_nassau_window(hole_results[9:18], total_holes=9),
overall=_nassau_window(hole_results[0:18], total_holes=18),
)
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- egen, dedikert visning (som Nassau) --
# passer IKKE inn i den ternære HoleResult-/status_text-cachen (se
# _compute_hole_results sin egen kommentar). Beregnes helt ved LESING.
# ---------------------------------------------------------------------------
class HighLowHighHoleOut(BaseModel):
hole_number: int
points_a: int
points_b: int
class HighLowHighResultOut(BaseModel):
ready: bool
points_a: int | None = None
points_b: int | None = None
holes: list[HighLowHighHoleOut] | None = None
@router.get(
"/orgs/{organization_id}/matches/{match_id}/high-low-high",
response_model=HighLowHighResultOut,
)
async def get_high_low_high_result(
match_id: str,
organization_id: str,
user: CurrentUser = Depends(get_current_user),
) -> HighLowHighResultOut:
"""Samme autorisasjon som get_scorecard/get_nassau_result over."""
async with org_connection(organization_id) as conn:
match = await conn.fetchrow(
"""
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
s.tournament_id::text AS tournament_id, s.format, s.hole_config::text AS hole_config,
s.course_id::text AS course_id, s.allowance_override::text AS allowance_override
FROM match m JOIN session s ON s.id = m.session_id
WHERE m.id = $1
""",
match_id,
)
if match is None:
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
if not await is_org_member(conn, organization_id, user.user_id) and not await user_is_tournament_participant(
conn, organization_id, match["tournament_id"], user.user_id
):
raise app_error(403, "NOT_ORG_MEMBER", "Du har ikke tilgang til denne turneringen.")
if match["format"] != "high_low_high":
raise app_error(400, "VALIDATION_FAILED", "Denne matchen bruker ikke High-low-high.")
allowance_override = json.loads(match["allowance_override"]) if match["allowance_override"] else None
config = parse_allowance_config(match["format"], allowance_override)
relative = await relative_strokes_for_match(conn, match_id, match["format"], config)
if not relative:
return HighLowHighResultOut(ready=False)
participant_rows = await conn.fetch(
"SELECT id::text AS id, team_side::text AS team_side FROM match_participant WHERE match_id = $1",
match_id,
)
by_side: dict[str, list[str]] = {"a": [], "b": []}
for p in participant_rows:
by_side[p["team_side"]].append(p["id"])
if len(by_side["a"]) != 2 or len(by_side["b"]) != 2:
return HighLowHighResultOut(ready=False)
stroke_index_rows = await conn.fetch(
"SELECT hole_number, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
match["course_id"],
)
all_18_si = [r["stroke_index"] for r in stroke_index_rows]
if len(all_18_si) != 18:
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
played = played_hole_numbers(match["hole_config"])
strokes_per_hole = {
pid: dict(zip(played, allocate_over_played_holes(total, all_18_si, played)))
for pid, total in relative.items()
}
score_rows = await conn.fetch(
"SELECT hole_number, match_participant_id::text AS match_participant_id, gross_strokes "
"FROM hole_score WHERE match_id = $1 AND match_participant_id IS NOT NULL",
match_id,
)
par_rows = await conn.fetch(
"SELECT hole_number, par FROM hole WHERE course_id = $1", match["course_id"]
)
par_by_hole = {r["hole_number"]: r["par"] for r in par_rows}
gross_by_hole: dict[int, dict[str, int]] = {}
for r in score_rows:
gross_by_hole.setdefault(r["hole_number"], {})[r["match_participant_id"]] = r["gross_strokes"]
hole_points: list[tuple[int, int]] = []
holes_out = []
for h in played:
scores_this_hole = gross_by_hole.get(h, {})
pids_a, pids_b = by_side["a"], by_side["b"]
if any(pid not in scores_this_hole for pid in pids_a + pids_b):
break
points_a_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_a
)
points_b_players = tuple(
stableford_points_for_hole(par_by_hole[h], scores_this_hole[pid], strokes_per_hole[pid][h])
for pid in pids_b
)
pa, pb = high_low_high_points_for_hole(points_a_players, points_b_players)
hole_points.append((pa, pb))
holes_out.append(HighLowHighHoleOut(hole_number=h, points_a=pa, points_b=pb))
if not hole_points:
return HighLowHighResultOut(ready=True, points_a=0, points_b=0, holes=[])
total_a, total_b = high_low_high_running_score(hole_points)
return HighLowHighResultOut(ready=True, points_a=total_a, points_b=total_b, holes=holes_out)

View file

@ -46,14 +46,20 @@ class TournamentCreate(BaseModel):
# 040_individual_tournaments.sql). scoring_method er kun meningsfullt
# for individual, håndhevet i app-laget når runder/score bygges på den.
format_type: str = Field(default="team", pattern="^(team|individual)$")
scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$")
scoring_method: str | None = Field(
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
# KUN meningsfullt når scoring_method='bingo_bango_bongo' -- valgfri
# organisator-innstilling (samme som frittstående runders round.
# bbb_sweep_bonus_enabled), ikke fast påslått.
bbb_sweep_bonus_enabled: bool = False
_TOURNAMENT_COLUMNS = """
id::text, name, status::text, start_date, end_date,
visibility, description, registration_deadline, registration_capacity,
registration_overflow_policy, registration_requires_approval, hero_image_key,
join_code, format_type, scoring_method
join_code, format_type, scoring_method, bbb_sweep_bonus_enabled
"""
# Uten 0/O/1/I -- lett å lese/skrive/si høyt (ADR-020 Beslutning A).
@ -80,6 +86,7 @@ class Tournament(BaseModel):
join_code: str
format_type: str
scoring_method: str | None
bbb_sweep_bonus_enabled: bool
@router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament])
@ -101,7 +108,7 @@ async def list_tournaments(
t.visibility, t.description, t.registration_deadline,
t.registration_capacity, t.registration_overflow_policy,
t.registration_requires_approval, t.hero_image_key,
t.join_code, t.format_type, t.scoring_method
t.join_code, t.format_type, t.scoring_method, t.bbb_sweep_bonus_enabled
FROM tournament t
LEFT JOIN (
SELECT tournament_id,
@ -132,8 +139,10 @@ async def create_tournament(
try:
row = await conn.fetchrow(
f"""
INSERT INTO tournament (organization_id, name, start_date, end_date, join_code, format_type, scoring_method)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO tournament
(organization_id, name, start_date, end_date, join_code, format_type,
scoring_method, bbb_sweep_bonus_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING {_TOURNAMENT_COLUMNS}
""",
organization_id,
@ -143,6 +152,7 @@ async def create_tournament(
_generate_join_code(),
body.format_type,
body.scoring_method,
body.bbb_sweep_bonus_enabled,
)
break
except asyncpg.UniqueViolationError:
@ -159,12 +169,15 @@ class TournamentUpdate(BaseModel):
status: str | None = Field(default=None, pattern="^(draft|active|completed|archived)$")
visibility: str | None = Field(default=None, pattern="^(public|org|participants)$")
format_type: str | None = Field(default=None, pattern="^(team|individual)$")
scoring_method: str | None = Field(default=None, pattern="^(stroke_gross|stroke_net|stableford)$")
scoring_method: str | None = Field(
default=None, pattern="^(stroke_gross|stroke_net|stableford|copenhagen|bingo_bango_bongo|flag)$"
)
description: str | None = None
registration_deadline: datetime | None = None
registration_capacity: int | None = Field(default=None, gt=0)
registration_overflow_policy: str | None = Field(default=None, pattern="^(waitlist|closed)$")
registration_requires_approval: bool | None = None
bbb_sweep_bonus_enabled: bool | None = None
# hero_image_key er BEVISST IKKE med her -- den settes kun via
# upload_hero_image under, aldri som en fritekst-streng klienten sender,
# siden den nå alltid kommer fra en ekte, server-konvertert opplasting.
@ -683,6 +696,9 @@ class SessionCreate(BaseModel):
scheduled_at: datetime | None = None
tee_interval_minutes: int | None = None
start_hole: int = 1
# KUN meningsfullt (og påkrevd) når format='shamble' -- antall av
# sidens individuelle resultater som teller per hull ("beste N av M").
shamble_best_n: int | None = Field(default=None, ge=1)
class SessionOut(BaseModel):
@ -698,6 +714,7 @@ class SessionOut(BaseModel):
scheduled_at: datetime | None
tee_interval_minutes: int | None
start_hole: int
shamble_best_n: int | None
locked_team_ids: list[str]
revealed: bool
@ -716,6 +733,7 @@ def _session_out(row, locked: set[str]) -> SessionOut:
scheduled_at=row["scheduled_at"],
tee_interval_minutes=row["tee_interval_minutes"],
start_hole=row["start_hole"],
shamble_best_n=row["shamble_best_n"],
locked_team_ids=sorted(locked),
revealed=len(locked) >= 2,
)
@ -730,7 +748,7 @@ async def _fetch_sessions(conn, tournament_id: str) -> list[SessionOut]:
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
FROM session
WHERE tournament_id = $1
ORDER BY sequence
@ -787,12 +805,12 @@ async def create_session(
INSERT INTO session
(organization_id, tournament_id, sequence, name, format,
hole_config, course_id, points_per_match, allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13)
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13, $14)
RETURNING id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
""",
organization_id,
tournament_id,
@ -807,6 +825,7 @@ async def create_session(
body.scheduled_at,
body.tee_interval_minutes,
body.start_hole,
body.shamble_best_n,
)
return _session_out(row, set())
@ -966,7 +985,7 @@ async def update_session(
SELECT id::text, sequence, name, format, hole_config::text AS hole_config,
course_id::text, points_per_match::float AS points_per_match,
allowance_override::text AS allowance_override, scoring_mode,
scheduled_at, tee_interval_minutes, start_hole
scheduled_at, tee_interval_minutes, start_hole, shamble_best_n
FROM session WHERE id = $1
""",
session_id,

View file

@ -699,3 +699,239 @@ def compute_skins(
"""
winnings, _log = compute_skins_detail(scores_by_hole, tie_handling)
return winnings
# ---------------------------------------------------------------------------
# Kobenhavner/Copenhagen (2026-07-30) -- flatt felt, noyaktig 3 spillere,
# INGEN sider (passer ikke ADR-011s to-lags-modell, se ADR-011s egen
# 2026-07-19-notat). Kilde: spilletyper-og-spilleformer-2023.pdf s.4 --
# 6 poeng deles per hull etter relativ rangering. Samme
# "(deltaker_id, verdi)-per-hull"-inputform som compute_skins_detail.
# ---------------------------------------------------------------------------
def copenhagen_points_for_hole(
values: Sequence[tuple[str, float]], higher_is_better: bool = False
) -> dict[str, int]:
"""Nøyaktig 3 (deltaker_id, verdi)-par for ETT hull. Returnerer
deltaker_id -> poeng, summerer ALLTID til 6 (verifisert av testene).
Fire rangeringsmønstre (verbatim fra kilden):
- alle tre ulike -> 4-2-0
- vinner alene, de to andre likt sist -> 4-1-1
- to delt best, én sist -> 3-3-0
- alle tre likt -> 2-2-2
`higher_is_better`: False (standard) rangerer LAVEST verdi som best --
/netto slagtall. True rangerer HØYEST verdi som best -- Stableford-
poeng. Motoren selv vet ikke, og bryr seg ikke om, hvilket kalleren gir
den (samme prinsipp som compute_skins).
"""
if len(values) != 3:
raise ValueError("copenhagen_points_for_hole krever nøyaktig 3 (deltaker_id, verdi)-par")
best_value = max(v for _, v in values) if higher_is_better else min(v for _, v in values)
worst_value = min(v for _, v in values) if higher_is_better else max(v for _, v in values)
best_ids = [pid for pid, v in values if v == best_value]
worst_ids = [pid for pid, v in values if v == worst_value]
if len(best_ids) == 3:
return {pid: 2 for pid, _ in values}
if len(best_ids) == 1 and len(worst_ids) == 2:
points = {best_ids[0]: 4}
points.update({pid: 1 for pid in worst_ids})
return points
if len(best_ids) == 2 and len(worst_ids) == 1:
points = {pid: 3 for pid in best_ids}
points[worst_ids[0]] = 0
return points
# Alle tre ulike -- nøyaktig én best, én verst, én i midten.
mid_id = next(pid for pid, _ in values if pid not in best_ids and pid not in worst_ids)
return {best_ids[0]: 4, mid_id: 2, worst_ids[0]: 0}
@dataclass(frozen=True)
class CopenhagenHoleLog:
"""Ett hulls poengfordeling -- til en hull-for-hull-visning (samme
idé som SkinsHoleLog)."""
values: dict[str, float]
points: dict[str, int] # tomt hvis hullet hoppes over (ikke alle 3 klare)
def compute_copenhagen_detail(
scores_by_hole: Sequence[Sequence[tuple[str, float]]], higher_is_better: bool = False
) -> tuple[dict[str, int], list[CopenhagenHoleLog]]:
"""Samme beregning som compute_copenhagen, men returnerer OGSÅ et
hull-for-hull-forløp. `scores_by_hole[i]` er (deltaker_id, verdi)-par
for hull i+1 -- et hull med færre/flere enn 3 par (noen har ikke
registrert ennå) hoppes stille over, ikke en feiltilstand."""
totals: dict[str, int] = {}
log: list[CopenhagenHoleLog] = []
for hole_values in scores_by_hole:
if len(hole_values) != 3:
log.append(CopenhagenHoleLog(values=dict(hole_values), points={}))
continue
points = copenhagen_points_for_hole(hole_values, higher_is_better)
for pid, p in points.items():
totals[pid] = totals.get(pid, 0) + p
log.append(CopenhagenHoleLog(values=dict(hole_values), points=points))
return totals, log
def compute_copenhagen(
scores_by_hole: Sequence[Sequence[tuple[str, float]]], higher_is_better: bool = False
) -> dict[str, int]:
"""Totale Københavner-poeng per deltaker, gitt verdier hull for hull."""
totals, _log = compute_copenhagen_detail(scores_by_hole, higher_is_better)
return totals
# ---------------------------------------------------------------------------
# Bingo Bango Bongo (2026-07-30) -- flatt felt, INGEN sider, poengbasert.
# Alle tre kategoriene registreres MANUELT av den som fører score, samme
# mønster som "valgt utslag" i scramble/greensome (migrasjon 036/039) --
# INGEN GPS/live-sporing, ingen ny interaksjons-modell.
# ---------------------------------------------------------------------------
def bbb_points_for_hole(
bingo_id: str | None,
bango_id: str | None,
bongo_id: str | None,
sweep_bonus_enabled: bool = False,
) -> dict[str, int]:
"""1 poeng hver til bingo (først på green), bango (nærmest hull når alle
er green), bongo (først i hull) -- en kategori som er None (ikke
registrert) gir ingen poeng til noen. Hvis `sweep_bonus_enabled` og
samme deltaker vant alle tre (ikke-None og like) gis 6 poeng i stedet
for 3 (kilden selv nevner dette som en "for variasjon"-valgfri
variant -- bekreftet av bruker at det skal være en organisator-
innstilling, ikke fast påslått)."""
if sweep_bonus_enabled and bingo_id is not None and bingo_id == bango_id == bongo_id:
return {bingo_id: 6}
points: dict[str, int] = {}
for pid in (bingo_id, bango_id, bongo_id):
if pid is not None:
points[pid] = points.get(pid, 0) + 1
return points
def compute_bbb(
hole_log: Sequence[tuple[str | None, str | None, str | None]],
sweep_bonus_enabled: bool = False,
) -> dict[str, int]:
"""Totale Bingo Bango Bongo-poeng per deltaker. `hole_log[i]` er
(bingo_id, bango_id, bongo_id) for hull i+1 -- hvilken som helst av de
tre kan være None (ikke registrert ennå, eller aldri registrert for et
upsilt hull)."""
totals: dict[str, int] = {}
for bingo_id, bango_id, bongo_id in hole_log:
for pid, p in bbb_points_for_hole(bingo_id, bango_id, bongo_id, sweep_bonus_enabled).items():
totals[pid] = totals.get(pid, 0) + p
return totals
# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30) -- flatt felt, individuelt,
# INGEN sider. Kilde: spilletyper-og-spilleformer-2023.pdf s.3-4. v1:
# hull-granularitet (ikke sub-hull/GPS -- bevisst utenfor omfang, se
# FEATURE_BACKLOG.md), ingen fortsettelse på hull 19+.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class FlagResult:
holes_completed: int # antall hull FULLFØRT innenfor slag-budsjettet
ran_out: bool # sann hvis budsjettet tok slutt FØR alle spilte hull var brukt opp
strokes_remaining: int # slag igjen etter siste fullførte hull (0 hvis brukt helt opp)
def flag_result(gross_strokes: Sequence[int], total_strokes_budget: int) -> FlagResult:
"""Par + spillehandicap gir et FAST antall slag for hele runden
(`total_strokes_budget`) -- flagget plantes der spilleren går tom.
`gross_strokes[i]` er brutto slag brukt hull i+1 (kun FAKTISK
spilte/registrerte hull, i rekkefølge). v1-granularitet er PER HULL:
et hull som ville krevd flere slag enn det som er igjen i budsjettet
telles IKKE som fullført -- vi vet ikke nøyaktig hvor det hullet
spilleren faktisk gikk tom (det ville krevd sub-hull/GPS-sporing)."""
remaining = total_strokes_budget
for i, score in enumerate(gross_strokes):
if score > remaining:
return FlagResult(holes_completed=i, ran_out=True, strokes_remaining=remaining)
remaining -= score
return FlagResult(holes_completed=len(gross_strokes), ran_out=False, strokes_remaining=remaining)
# ---------------------------------------------------------------------------
# Shamble (2026-07-30) -- team av 2-4 spillere (fleksibel størrelse,
# bekreftet av bruker). Alle slår ut, laget velger beste utslag, alle
# spiller egen ball resten av hullet. Lagets hullscore = sum av de N
# beste individuelle resultatene ("beste N av M", N organisator-
# konfigurerbart -- kilde: livetourney.com, "2 Best Balls of 4" mest
# vanlig, men N er ikke fast).
# ---------------------------------------------------------------------------
def shamble_hole_score(individual_scores: Sequence[int], best_n: int) -> int:
"""Summen av de `best_n` LAVESTE individuelle (netto eller brutto,
kalleren avgjør) scorene blant lagets spillere for ETT hull.
`individual_scores` har én oppføring per lagkamerat som spilte hullet.
"""
if not (1 <= best_n <= len(individual_scores)):
raise ValueError(
f"best_n må være mellom 1 og antall spillere ({len(individual_scores)}), fikk {best_n}"
)
return sum(sorted(individual_scores)[:best_n])
# ---------------------------------------------------------------------------
# Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag. Én spiller er
# "money ball" per hull, roterer deterministisk gjennom en FAST
# spillerrekkefølge. Lagets hullscore = money-ball-spillerens score +
# laveste av de tre andre (2 av 4 scorer teller alltid).
# ---------------------------------------------------------------------------
def money_ball_hole_score(scores_in_fixed_order: Sequence[int], hole_number: int) -> int:
"""`scores_in_fixed_order`: nøyaktig 4 individuelle scorer, indeks i =
spilleren som er money-ball hull i+1 (rotasjonsindeks =
(hole_number - 1) % 4 -- spiller 0 hull 1, spiller 1 hull 2, osv,
tilbake til spiller 0 hull 5). Returnerer money-ball-spillerens
score PLUSS laveste av de tre andre."""
if len(scores_in_fixed_order) != 4:
raise ValueError(
f"money_ball_hole_score krever nøyaktig 4 scorer, fikk {len(scores_in_fixed_order)}"
)
mb_index = (hole_number - 1) % 4
others = [s for i, s in enumerate(scores_in_fixed_order) if i != mb_index]
return scores_in_fixed_order[mb_index] + min(others)
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- 2 lag à 2 spillere. Per hull rangeres
# lagkameratenes Stableford-poeng til "high"/"low" ETT HULL OM GANGEN (ikke
# faste roller), to uavhengige duelløer (high mot high, low mot low). Hver
# duell gir 1 poeng til vinneren, UAVGJORT gir NULL poeng til begge ("å
# dele et hull" = halvere, IKKE en 0,5-splitt -- verifisert tall for tall
# mot brukerens eget eksempel 2026-07-30, se ADR/plan for utregningen).
# ---------------------------------------------------------------------------
def high_low_high_points_for_hole(
team_a_points: tuple[int, int], team_b_points: tuple[int, int]
) -> tuple[int, int]:
"""`team_a/b_points`: Stableford-poeng for de to lagkameratene på DETTE
hullet (rekkefølge i tuppelet er irrelevant -- rangeres internt til
high/low). Returnerer (poeng_a, poeng_b), til sammen 0, 1 eller 2."""
high_a, low_a = max(team_a_points), min(team_a_points)
high_b, low_b = max(team_b_points), min(team_b_points)
points_a = points_b = 0
if high_a > high_b:
points_a += 1
elif high_b > high_a:
points_b += 1
if low_a > low_b:
points_a += 1
elif low_b > low_a:
points_b += 1
return points_a, points_b
def high_low_high_running_score(hole_points: Sequence[tuple[int, int]]) -> tuple[int, int]:
"""Løpende STILLING -- literal poengsum per lag (IKKE match-play "up"/
"down"-terminologi), summert over alle spilte hull."""
return sum(p[0] for p in hole_points), sum(p[1] for p in hole_points)

View file

@ -34,6 +34,17 @@ from handicap_engine import (
match_play_strokes,
compute_skins,
compute_skins_detail,
copenhagen_points_for_hole,
compute_copenhagen,
compute_copenhagen_detail,
bbb_points_for_hole,
compute_bbb,
FlagResult,
flag_result,
shamble_hole_score,
money_ball_hole_score,
high_low_high_points_for_hole,
high_low_high_running_score,
max_hole_score_for_handicap,
net_par,
round_counts_for_handicap,
@ -622,6 +633,256 @@ def test_skins_detail_empty_hole_logged_but_inert():
assert log[1].awarded == {"a": 1.0}
# ---------------------------------------------------------------------------
# Kobenhavner/Copenhagen (2026-07-30) -- 6 poeng per hull mellom 3 spillere.
# Fasitverdiene er verbatim fra spilletyper-og-spilleformer-2023.pdf s.4,
# de fire rangeringsmonstrene (4-2-0/4-1-1/3-3-0/2-2-2).
# ---------------------------------------------------------------------------
def test_copenhagen_all_distinct_gives_4_2_0():
# Lavest slag vinner (higher_is_better=False, standard) -- a=4 slag best.
points = copenhagen_points_for_hole([("a", 4), ("b", 5), ("c", 6)])
assert points == {"a": 4, "b": 2, "c": 0}
assert sum(points.values()) == 6
def test_copenhagen_winner_alone_other_two_tied_gives_4_1_1():
points = copenhagen_points_for_hole([("a", 4), ("b", 5), ("c", 5)])
assert points == {"a": 4, "b": 1, "c": 1}
assert sum(points.values()) == 6
def test_copenhagen_two_tied_best_one_worst_gives_3_3_0():
points = copenhagen_points_for_hole([("a", 4), ("b", 4), ("c", 5)])
assert points == {"a": 3, "b": 3, "c": 0}
assert sum(points.values()) == 6
def test_copenhagen_all_tied_gives_2_2_2():
points = copenhagen_points_for_hole([("a", 4), ("b", 4), ("c", 4)])
assert points == {"a": 2, "b": 2, "c": 2}
assert sum(points.values()) == 6
def test_copenhagen_higher_is_better_for_stableford_points():
# Stableford: HØYEST poengsum vinner -- a=5 poeng best her.
points = copenhagen_points_for_hole([("a", 5), ("b", 3), ("c", 2)], higher_is_better=True)
assert points == {"a": 4, "b": 2, "c": 0}
def test_copenhagen_rejects_wrong_player_count():
try:
copenhagen_points_for_hole([("a", 4), ("b", 5)])
assert False, "skulle kastet ValueError"
except ValueError:
pass
def test_copenhagen_total_matches_hand_computed_sum():
scores = [
[("a", 4), ("b", 5), ("c", 6)], # a: 4, b: 2, c: 0
[("a", 5), ("b", 5), ("c", 4)], # c alene best -> c:4, a/b delt sist -> 1/1
[("a", 4), ("b", 4), ("c", 4)], # alle likt -> 2/2/2
]
totals = compute_copenhagen(scores)
assert totals == {"a": 4 + 1 + 2, "b": 2 + 1 + 2, "c": 0 + 4 + 2}
assert totals == {"a": 7, "b": 5, "c": 6}
assert sum(totals.values()) == 18 # 3 hull * 6 poeng
def test_copenhagen_incomplete_hole_skipped_not_an_error():
scores = [[("a", 4), ("b", 5)], [("a", 4), ("b", 5), ("c", 6)]]
totals = compute_copenhagen(scores)
assert totals == {"a": 4, "b": 2, "c": 0}
def test_copenhagen_detail_log_matches_totals():
scores = [[("a", 4), ("b", 5), ("c", 6)]]
totals, log = compute_copenhagen_detail(scores)
assert totals == {"a": 4, "b": 2, "c": 0}
assert len(log) == 1
assert log[0].values == {"a": 4, "b": 5, "c": 6}
assert log[0].points == {"a": 4, "b": 2, "c": 0}
# ---------------------------------------------------------------------------
# Bingo Bango Bongo (2026-07-30)
# ---------------------------------------------------------------------------
def test_bbb_points_for_hole_three_different_winners():
points = bbb_points_for_hole("a", "b", "c")
assert points == {"a": 1, "b": 1, "c": 1}
def test_bbb_points_for_hole_one_winner_all_three():
points = bbb_points_for_hole("a", "a", "a")
assert points == {"a": 3}
def test_bbb_points_for_hole_sweep_bonus_gives_six():
points = bbb_points_for_hole("a", "a", "a", sweep_bonus_enabled=True)
assert points == {"a": 6}
def test_bbb_points_for_hole_sweep_bonus_off_gives_three():
points = bbb_points_for_hole("a", "a", "a", sweep_bonus_enabled=False)
assert points == {"a": 3}
def test_bbb_points_for_hole_none_categories_award_nothing():
points = bbb_points_for_hole(None, "b", None)
assert points == {"b": 1}
def test_bbb_points_for_hole_all_none_is_empty():
assert bbb_points_for_hole(None, None, None) == {}
def test_compute_bbb_totals_across_holes():
hole_log = [
("a", "b", "c"), # a:1 b:1 c:1
("a", "a", "a"), # a:3 (uten sweep-bonus)
(None, "b", "b"), # b:2
]
totals = compute_bbb(hole_log)
assert totals == {"a": 1 + 3, "b": 1 + 2, "c": 1}
assert totals == {"a": 4, "b": 3, "c": 1}
def test_compute_bbb_with_sweep_bonus():
hole_log = [("a", "a", "a"), ("b", "c", "b")]
totals = compute_bbb(hole_log, sweep_bonus_enabled=True)
# Hull 1: a sveiper -> 6 poeng. Hull 2: b vinner 2 av 3 (bingo+bongo), c 1 -> b:2, c:1
assert totals == {"a": 6, "b": 2, "c": 1}
# ---------------------------------------------------------------------------
# Flaggturnering/Flag tournament (2026-07-30)
# ---------------------------------------------------------------------------
def test_flag_result_completes_all_holes_with_budget_left():
result = flag_result([5, 4, 6], total_strokes_budget=20)
assert result == FlagResult(holes_completed=3, ran_out=False, strokes_remaining=5)
def test_flag_result_exact_budget_no_remainder():
result = flag_result([5, 4, 6], total_strokes_budget=15)
assert result == FlagResult(holes_completed=3, ran_out=False, strokes_remaining=0)
def test_flag_result_runs_out_mid_round():
# Budsjett 10: hull 1 (5) -> 5 igjen. Hull 2 (4) -> 1 igjen. Hull 3
# krever 6, men kun 1 igjen -- går tom PÅ hull 3, kun 2 hull fullført.
result = flag_result([5, 4, 6], total_strokes_budget=10)
assert result == FlagResult(holes_completed=2, ran_out=True, strokes_remaining=1)
def test_flag_result_runs_out_on_first_hole():
result = flag_result([5, 4, 6], total_strokes_budget=3)
assert result == FlagResult(holes_completed=0, ran_out=True, strokes_remaining=3)
def test_flag_result_no_holes_played_yet():
result = flag_result([], total_strokes_budget=90)
assert result == FlagResult(holes_completed=0, ran_out=False, strokes_remaining=90)
# ---------------------------------------------------------------------------
# Shamble (2026-07-30)
# ---------------------------------------------------------------------------
def test_shamble_hole_score_two_best_of_four():
# Konkret eksempel fra kilden: scores 4,5,4,6 -- "2 Best Balls of 4" -> 4+4=8.
assert shamble_hole_score([4, 5, 4, 6], best_n=2) == 8
def test_shamble_hole_score_one_best_of_two():
assert shamble_hole_score([5, 3], best_n=1) == 3
def test_shamble_hole_score_all_count_equals_sum():
assert shamble_hole_score([4, 5, 6], best_n=3) == 15
def test_shamble_hole_score_rejects_best_n_out_of_range():
try:
shamble_hole_score([4, 5], best_n=3)
assert False, "skulle kastet ValueError"
except ValueError:
pass
try:
shamble_hole_score([4, 5], best_n=0)
assert False, "skulle kastet ValueError"
except ValueError:
pass
# ---------------------------------------------------------------------------
# Money Ball/Lone Ranger (2026-07-30)
# ---------------------------------------------------------------------------
def test_money_ball_hole_1_uses_player_0():
# Hull 1 -> rotasjonsindeks 0. Money-ball=scores[0]=5, laveste av [4,6,7] er 4.
assert money_ball_hole_score([5, 4, 6, 7], hole_number=1) == 5 + 4
def test_money_ball_hole_2_uses_player_1():
# Hull 2 -> rotasjonsindeks 1. Money-ball=scores[1]=4, laveste av [5,6,7] er 5.
assert money_ball_hole_score([5, 4, 6, 7], hole_number=2) == 4 + 5
def test_money_ball_rotation_wraps_after_four_holes():
# Hull 5 -> (5-1)%4 = 0, samme som hull 1.
assert money_ball_hole_score([5, 4, 6, 7], hole_number=5) == money_ball_hole_score([5, 4, 6, 7], hole_number=1)
def test_money_ball_rejects_wrong_player_count():
try:
money_ball_hole_score([4, 5, 6], hole_number=1)
assert False, "skulle kastet ValueError"
except ValueError:
pass
# ---------------------------------------------------------------------------
# High-low-high (2026-07-30) -- verifisert tall for tall mot brukerens eget
# eksempel: lag 1 = A+B, lag 2 = C+D. Hull 1: A=3, B=0(stryk), C=2, D=2.
# Hull 2: A=1, B=2, C=3, D=1. Etter hull 1: 1-1. Etter hull 2: 2-1 til lag 2.
# ---------------------------------------------------------------------------
def test_high_low_high_hole_1_matches_user_example():
# High: A(3) vs C-eller-D(2, likt) -> A vinner (3>2) -> 1p lag1.
# Low: B(0) vs C-eller-D(2, likt) -> lag2 vinner (2>0) -> 1p lag2.
points = high_low_high_points_for_hole((3, 0), (2, 2))
assert points == (1, 1)
def test_high_low_high_hole_2_matches_user_example():
# High: B(2) vs C(3) -> lag2 vinner -> 1p lag2.
# Low: A(1) vs D(1) -> UAVGJORT -> 0p til begge (IKKE 0.5/0.5-splitt).
points = high_low_high_points_for_hole((1, 2), (3, 1))
assert points == (0, 1)
def test_high_low_high_running_score_matches_user_example():
hole1 = high_low_high_points_for_hole((3, 0), (2, 2))
hole2 = high_low_high_points_for_hole((1, 2), (3, 1))
running_after_1 = high_low_high_running_score([hole1])
assert running_after_1 == (1, 1), running_after_1 # "1-1 etter hull 1"
running_after_2 = high_low_high_running_score([hole1, hole2])
assert running_after_2 == (1, 2), running_after_2 # "2-1 til lag 2" (lag2=2, lag1=1)
def test_high_low_high_tie_on_both_duels_gives_no_points():
points = high_low_high_points_for_hole((2, 2), (2, 2))
assert points == (0, 0)
def test_high_low_high_sweep_gives_two_points():
points = high_low_high_points_for_hole((4, 3), (2, 1))
assert points == (2, 0)
# ---------------------------------------------------------------------------
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
# Stableford