From 7ae554062c0a1daa41c6af53d22d71c080ec6278 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 30 Jul 2026 14:54:11 +0200 Subject: [PATCH] =?UTF-8?q?All=20eight=20new=20turneringsformater=20have?= =?UTF-8?q?=20backend=20+=20engine=20complete=20and=20scratch-verified=20?= =?UTF-8?q?=E2=80=94=20Chapman,=20Nassau,=20K=C3=B8benhavner,=20Bingo=20Ba?= =?UTF-8?q?ngo=20Bongo,=20Flaggturnering,=20Shamble,=20Money=20Ball,=20and?= =?UTF-8?q?=20High-low-high.=20Built=20and=20tested=20one=20format=20at=20?= =?UTF-8?q?a=20time=20(engine=20tests=20=E2=86=92=20migration=20=E2=86=92?= =?UTF-8?q?=20API=20wiring=20=E2=86=92=20scratch=20verification)=20exactly?= =?UTF-8?q?=20per=20the=20approved=20plan:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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"). --- 041_chapman_format.sql | 25 + 042_copenhagen_round_and_tournament.sql | 30 + 043_bbb_round_format.sql | 34 + 044_bbb_individual_tournament.sql | 48 ++ 045_flag_round_format.sql | 19 + 046_flag_individual_tournament.sql | 12 + 047_shamble_round_and_session.sql | 31 + 048_money_ball_round.sql | 21 + 049_money_ball_session.sql | 17 + 050_high_low_high_round_and_session.sql | 20 + CLAUDE.md | 52 ++ FEATURE_BACKLOG.md | 129 ++- __pycache__/handicap_engine.cpython-312.pyc | Bin 30044 -> 41553 bytes app/handicap.py | 39 + app/routers/individual_tournaments.py | 331 +++++++- app/routers/matches.py | 56 +- app/routers/rounds.py | 839 +++++++++++++++++++- app/routers/scoring.py | 331 +++++++- app/routers/tournaments.py | 41 +- handicap_engine.py | 236 ++++++ test_handicap_engine.py | 261 ++++++ 21 files changed, 2529 insertions(+), 43 deletions(-) create mode 100644 041_chapman_format.sql create mode 100644 042_copenhagen_round_and_tournament.sql create mode 100644 043_bbb_round_format.sql create mode 100644 044_bbb_individual_tournament.sql create mode 100644 045_flag_round_format.sql create mode 100644 046_flag_individual_tournament.sql create mode 100644 047_shamble_round_and_session.sql create mode 100644 048_money_ball_round.sql create mode 100644 049_money_ball_session.sql create mode 100644 050_high_low_high_round_and_session.sql diff --git a/041_chapman_format.sql b/041_chapman_format.sql new file mode 100644 index 0000000..bf3c239 --- /dev/null +++ b/041_chapman_format.sql @@ -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')); diff --git a/042_copenhagen_round_and_tournament.sql b/042_copenhagen_round_and_tournament.sql new file mode 100644 index 0000000..55d0137 --- /dev/null +++ b/042_copenhagen_round_and_tournament.sql @@ -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; diff --git a/043_bbb_round_format.sql b/043_bbb_round_format.sql new file mode 100644 index 0000000..e376dd5 --- /dev/null +++ b/043_bbb_round_format.sql @@ -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; diff --git a/044_bbb_individual_tournament.sql b/044_bbb_individual_tournament.sql new file mode 100644 index 0000000..c21fd17 --- /dev/null +++ b/044_bbb_individual_tournament.sql @@ -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); diff --git a/045_flag_round_format.sql b/045_flag_round_format.sql new file mode 100644 index 0000000..8a07e65 --- /dev/null +++ b/045_flag_round_format.sql @@ -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')); diff --git a/046_flag_individual_tournament.sql b/046_flag_individual_tournament.sql new file mode 100644 index 0000000..82f2a04 --- /dev/null +++ b/046_flag_individual_tournament.sql @@ -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')); diff --git a/047_shamble_round_and_session.sql b/047_shamble_round_and_session.sql new file mode 100644 index 0000000..308479a --- /dev/null +++ b/047_shamble_round_and_session.sql @@ -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); diff --git a/048_money_ball_round.sql b/048_money_ball_round.sql new file mode 100644 index 0000000..a4a39e0 --- /dev/null +++ b/048_money_ball_round.sql @@ -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; diff --git a/049_money_ball_session.sql b/049_money_ball_session.sql new file mode 100644 index 0000000..bbd959d --- /dev/null +++ b/049_money_ball_session.sql @@ -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; diff --git a/050_high_low_high_round_and_session.sql b/050_high_low_high_round_and_session.sql new file mode 100644 index 0000000..f925a5e --- /dev/null +++ b/050_high_low_high_round_and_session.sql @@ -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')); diff --git a/CLAUDE.md b/CLAUDE.md index 6915419..69ec615 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 3947ac6..a9c4378 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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 diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc index 9ca7fdf6650674c52055d85201f5076708278ce0..706f74d38acab1627714b09a788a7f40533f235e 100644 GIT binary patch delta 11682 zcmbtadr%z5nV;D=yS!GsBtU5N!mM~m2w4wXlJx)t5<)^qwjzXa4xyZB1pX1D30oq)Ln8Z2V2hBO4U`} z@9WuJK*GMe+(7m8bocl5*WKT{_s;M6>9-8oKg!Cob9nZz>F?|P>O}T(!yWTPMK?b< zajB&FteEkv>D00e?%_XC7VYTE6BW^ccfP2KnRpk7Sz`7p{NnLqcw%nVsv$A=S<9)S z3{E_^xSPJj^I8o*)^mI@r z^*+mq-ESD^1UT*u{l%Ty`vAn3L=h1C9%y|*l+e0=acgQGaa`;{Z~fw4aYFQ=wZXti zCNb;!W7W3YGOa$P2x*p0Pp714D=SiEDoUUp!gLri1(oj zA=l-PA(Py2FDTZ(1`??BQS6I$L(~zqo?SAl%JrMcQv`Kg8zf|a@(7h z_o(4L&2wxm|DG0oY{kv5ANz@+;A3k2kczjd_)jYC-+cN+2VZRBQ7@y05-L_uagP<+ zM^A1s+~c%&PcOgO@Km3nk|FpL^)Unt>3w{H#%^kTj0!fvJuUH>)dfqA*K$v7;O#db zKhr=k-e4&p zkOJ**-F1Ay&0o9S#@r%mSx!YU6(!oS?tkF#X_?|0zChb39$U-$dI^_t&@amS7}j(C zU{I2k6wJ589em}@cf}{n%PN5dD_Tp))={xu+t~LAU!y(Mw?SLKw$S!5c=Wl9=j_@S z%a&`eZ7@bWU%47BVKjn8rs?VCIEoI$5*O}Co#*8F?}8$RD*UNC$fiA8TMh(@@2 z?gi5e=5C{p>o#;|aolOlKWrK{N6ZmJe+9=W{H4r@X~J+R$Ha9Tht0#L=QBPP%J>OT zb`{IjpRtTL^m>#Nc^WPkCQ(|tjea7;mL|j!F^N{qaLLZ5w+x%wxg`*BqK)C%wxG|R zoP-mttj{%;A!ZNim^GNLJp=HFv>+L3MpBLh1&Yytr`5MTuXhGA0*DbGc$ zpFoN}UB!~5=wud;Ww2THEuMzgrx~^mo5YNWb9t4Jq8#A8}UMZ z)Hc7gh)qlbiWz=mJE-Q9CwFeCI<_>~j@2!!xiZ7Aff-t-Ze8&7tA3wQCsc@1K=t%X zvfD3K2}6=B`d!t7o~SJJgk+)N_;H~(90=41$0Ri@Ljok(F5p=JB~}_c|-dr!J{(!Jmr~p=fk6)2wRqzZ6s-&n=P;%AS8Ky?RED5qF=)<6(PZ^lH z6;xC?DlssxNA(W~J+epW_Xk8N=z5I72tu{s2?QiT1#CFr@0W+qpR2N>&0P2{fgTwsj4J*Jtj1I0t)s+Q9-Il7G25h!ald` zeX^nbxPEMc5*lC>3f0w&a(U|2jX~fD2};1@Q$bCQa9j?jb{pVLQ21*+~WH;N-(77VR1xQXR+f_eeQLzic?f@!VxD!gb~) zEeAggQ#fWE;$grXsF_$TH^P5~zs&R8r2-Rof%}f}94~*JCLkZhW!4#?0>>jWZm^LI zL)R&ZjB_DbuPS6l!5coXmi&A&&N8CI6JPCPp+Ljd#(G{^4m3g-|UXH zox0t4>ZP^Q&W`&^CI&u}S;~)_O8GBaOL>1%!Vc)q zo;`AuL?u!D$xeku(4YSS-Z|w&!$DTYrd!TcszH{W@&o@4SZ7Ha{+BjQKIlHX#t@i1r=$Y74 zowH46W*W|n9Ez{0cqeD)70aY?vhCX58^v!NdC@qNvvZ^=zSi|l?(QpvlX;V#Yi&0w zXR3F-m^YKVd!+d{R{QAoXND*F+twBF^_B1BR9)FRS$CBmH;%VWSY~pnVz#O~3&yx@j-Q<CTRs ztd5b!cxL{1+t?xZq;sfWmtZaBXgxzQGLY>Sz;-M_br%iVSV7rDFd-?MPJ)%QQJ za7A0FTvnrOz=3_gI;%mjeE)G1${*OW8p%9_pX` zglcqDv&xWP2~xjq2$-(-i&L*NL(onJAc@^|rbb>tY{=a5LaOaX(JGfx^?^8JAGJ~O zM`AqEH(%i1H}U3Ga~3XZ<&3E`o>@F=D%K+t8rN;maI{ z{J?pCE|T^F;l6IeEaeenA8Diqo_V)@-c_H6^>fz^>1Ysh(gVd5-KJ*fAMpB25k579 zT=KrO2bdZ$AIyTxl}X)afp=@@gERl&6S0WK6uyY{BDy1nh=q>x(kc_DFXF_5K%c&f zhm#MonKN4OKt<+UB&GW)!O&`x_-XumvDjKJqkPkA)$N$NfU_<}Kr;IFJD zo`K&$Y$6OuqR=z-ZCRE!_K+7LNWmbi`=Cb_DuQq-gMybdOWJMGe7!QshVI!$D4on<-d@F{`d-m!VHVl*6UeXp~u##EO*PGrw1E zQBpnrfP4TT6dfwp0XD+TWpiZ$RKVKxBm3h;>p$SE<|F*5V`kIdx7W-T9iGi>h2ps5th{z8wxfB*c@TQ5&|7*rFcHv#A8>p| z?O5aJ{&8o#Sop?Ty^d2bn&Lq#__u656u}d=B~~0f_=*SlXiGBG9m`OQAjPkMw z_xaoi7vZ}N=UJdE+w_20Hb)TE`uTPUGn9yl#zYJ~MqNHhibwcMh-&*V`GZfyET&b0 zXk_wfiZFG{be};sLo~c{#Q-r~(R|TIeAqJ`vya<=j z6NKOlz@5=I5%M6jsECOQa4dodEyU$#5H2Q&(I7l{Rq};oKU2;CYz+kwZY&{nF7ojO zkOjfiPnPH;Ijs?zpz(z+<(wo9y1k)bSaC~13L(TUA>?Bu=^;Ui4}>945bPS#HU_4jBr@+sDw*U5Z(ww*l1EsLP+*` zf_}xL!pt!&h$oru!Jg=fN9ghBhouCjUR70BjeHa=p}V}~{DQ+h^s*#u?0gaFiKxe1ntt#r=F?f49TcjYQ*Av9K8ZHnbof4~{cwWG$kBYW(L zPgWpi41yWXTsCr86V{eQCo!dNbD6!}2SWx*hK-boT3~mLFo_4B)82A zp&&nC_NoMlLT(6;2%%ljOYEMMkZ5>N@grqHlYX<1!{Z65Gy3jjQDE@Ek)ca(o=8W^ahWstE)_j%rjbp{yd#CJfUNSCl zXYGwheH@dNa6pwQfKTMmf)zK>9gv_AI&F!a}BmRuE6=ugBb!tB5|OSVuJu$_D|)5Ri4mf_NB6Q>%r z>8*L%SIg{CXm1`^Ge0MHlmh+^LQ7hDD#Gb}?hpgU!Z=Fc|Ozqsa4tv*qMmEI~vTN(~L@sh=*N z98#1No}^Hb_DyB1m?qW@Us{AJ`olrcdFXg^`yoaStwfbJ&I^IWSiVY#l4QVfg!v5k zkZlx(YO8%X=5PS^fK)w5JKAteXgbl-qGuJ+#^xc}L~aYqroj@ypXJ9#Uwd2Zz+ zQ!<;^aXYgkZgY;Gn6@FcK4-;XICGhW-|D_rJyX8@&GoSkan>QlOj6PnO$rIU$Tk|a zqwS_B3GQY1Zb_dGPNAH=l<@VVM=1^RJOqd^&?7D>rZ01%F~YyheJvd@KFu!~PLW`1 zUoW#n5%3kKr5ti-4$lC4JEh8ly7m{kSpFv1mCWZhz{L{%Lw+$#j08UqtQAV?d|f`O z81JVuubv@aAK1$!DBU5jiX?a;fU1DQkW}ztiiZ}b^cjk-)J&d$n*l3bEaajbH$p37 zTJR8a;DN2*!#IZVv@Uo_R!t^z$-xQNpwQPN41k}by7M%ErGRH^;S8o#3{yhn`dzfa zsqG=YCT;r|5XdYO)=f1p3YQ__e2CJ7MW03?p*B`l8f9sPqW_t{zGeXD zu;*#;En)`z$Ux*Tf~jFR(k@RV%~H5_GU9q`@pT`W5EuZOGkM(?W%qr!?V~PO4NCAW z$YGdMlU_u7`i99gY&=Kt$$n7OrSpf|A9VNl&r714azJ!Fk6eM z!vIF^Hxs$vm_wMcuD;rK+gi?Kss63@SThp1?pS?SEYstkmm!1+&eBQGM9$UP>6~>V zO?OJy$I2g{Dc$)_R_jFl_}=leZ}M-RoXUFM6U%BHX`Ie#jXQ*^%cmV1uD9PPe4}I9 zu{~zme*d14%Ra)h)b;nW_O7vf-)=&wCnfUV0%4aI+$)p3Xmhq=Blusd-B}Qw7equK zSPngO7cTPW`C*oBi7U z*0*px^{u2R#2Ef5X6;fX&w$&rF%`c-q_m&37DV;y$$7O)ik%p^9=Ea$jN^1aL8w^Z z=s?KSFmht>ADD-N%vwNVp*G}0zh;GBhWj;1hJ^s@>xgjcxG@kqw~*B#FcmEHc6+My z7~JiJnx=@KMe;#mQx!x?4@EZXA%@PK26ly^^Q0}2r7tfToF+e0YfV__GQ;gjm@zwE z`lpvZqvzb-ge`d&=tJmtQwt zJ3LeI*l5E@v>$J7X=!dfKx!`le^y4Cf}Ixb;8W&Ws9+xGfJg1_ zt%gs2L0A(WT(gm#_!=>E4jB;u4)}v1M74gLt$u&6vOFqFB{AJ;gB~MJtHIrBd32bJ z5=Yh}dF|%FwHr^@ZpgHfC2nO|`_~Qn%^M3G7Z_;yyBKn@X7Pa`yr57^_du4oe|?<} zPrrXH_!t+k*{(@`^z69vi|TZ?D|vNU@#&P2Yob#o(-trE8UDW{5k)o4x1#*Dvp1aI zQKxH~lQ*8f#GEcZnJ?CvQXx&FUqmNLK4lZ+a~Sw-TK0MXfM?0=z+xvmX0u&04%hWM zOmnMawx)TevN`5xj+vUZNZ(!lBdxQ)-fo7Dc+S98=VO zUi+`W`Y0Rs0)1Zu1Tx+~qB?D~{0cUk=i#0R#!6|pAN^%J1`pPZQ36Pii$W&B$0$)Jm0 zr;P;LJ?wW`5dvX9&Kjl~gQHs~7vBi9boF$xq+L22mV(_<2lQt^>Og!8pEMBisGXfM zOGMND>7aB?1vo`Wq+KL^Z9 zPHLAA(WG`zCq!7HmSt+9GTp-J`5IPZ*_?+6Zo{C9bq_Bzi3$oJjdo!j8?ZFK^szyObM!sL&XFsrcFL4zmq(~z2Z`E7 zV(cNbTwL+M>e5i1>A{?<<>m*$Qa<`CI?&%zLBE`o|3C#>!h2LBzLM=U3;Wq&9@W^d z2`i~qL&Y8{=$?_?TCmFpJ;g?;Fg>iMAehA;%x7Yzh{-mTq}DcAVE?~X_XNG delta 1257 zcmZuvNla5=5N6s(7iy7UODQ4+!C;FDT5v}^c+dk{+Gq@j6g$raQ1l@xxE@H1Mk8cY z64RRpHG0t(4<1|+J!ptAdT|Bgav&;+qM|3`%&!rM^flis-+c4W``DdhdeM#P zNf=*`TY5eR0$VYo%U0`irr@^mv_#lMbKqD_GtT2Cx!8hXw$pGsmb1_LokwyL8sc<8*tW`|7E>t4GY zg4A5S6b8(T)yDz)DZFMDyf@=(N+sxyEQCSxK_m}+gZx>7nsJ$pQ7G_6;4S^CX?MR9 z&T3Rirk{>PXTnFi6un~`lEe_T#tLDGPA>7AS7MJW-XD_vDsW%myTDJgdtDvmSb_aK zNlX>+3k)lu)b-^sOl3uWGq%3TQlj+Vgi?Cd^CyQqVnVWu1yuiG8mukwPFPQQbs=z? ztLipeY%V$4Mc3;qEH0qAMcJfn{Ka=7xqM>VQkZJb&yWRLpG1d zJajW229NnA-eAq}N+wGnTfk3oya8>LqfZA9Rq9d5qHaCc_Lsb-FM?w8lU|jYSq@K&am@Pot~J!t6krb(Cb#Vw&+p4t-WP;z2T5~ReLq6tOXA>Sp*sX1d*1;4&A6X zGNkw@UG4QlfckoakWb%xV*z!yrNXHTE)!36^RX9PZW`xup>jap?!AEQyzx;$ovYSa rt)o+fYGX3RQ%fM9&j^t{InA|zUH+%Ez17Up 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( """ diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 449e28d..88742a8 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -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 PÅ TVERS av spillere per hull, ikke -bare uavhengige rene slagspill-/Stableford-poeng) og Order of Merit -(sesong-sammenlagt på 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 PÅ 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 på 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 på relativ NETTO-rangering -- kan derfor IKKE regnes + per deltaker isolert slik gross/net/stableford ellers gjøres (se + modulens docstring, "bevisst utenfor omfang" er nå 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 på 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 diff --git a/app/routers/matches.py b/app/routers/matches.py index 29b1c13..e37dfc6 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -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 diff --git a/app/routers/rounds.py b/app/routers/rounds.py index e93cbb2..06743a7 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -53,9 +53,18 @@ from handicap_engine import ( allocate_over_played_holes, allocate_strokes_by_index, apply_index_caps, + bbb_points_for_hole, + compute_bbb, + compute_copenhagen_detail, compute_match_state, compute_skins_detail, course_handicap, + flag_result, + high_low_high_points_for_hole, + high_low_high_running_score, + money_ball_hole_score, + shamble_hole_score, + stableford_points_for_hole, course_handicap_raw, handicap_index_from_differentials, low_handicap_index, @@ -534,7 +543,7 @@ async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[b 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 in ("stroke", "stableford"): + if play_format in ("stroke", "stableford", "flag"): return True, None if play_format == "skins": @@ -543,6 +552,45 @@ async def _format_setup_status(conn, round_id: str, play_format: str) -> tuple[b return False, f"Skins krever minst {_SKINS_MIN_PARTICIPANTS} spillere -- runden har {total}." return True, None + if play_format == "copenhagen": + total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id) + if total != _COPENHAGEN_PLAYER_COUNT: + return False, f"Københavner krever nøyaktig {_COPENHAGEN_PLAYER_COUNT} spillere -- runden har {total}." + return True, None + + if play_format == "bbb": + total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id) + if total < _BBB_MIN_PARTICIPANTS: + return False, f"Bingo Bango Bongo krever minst {_BBB_MIN_PARTICIPANTS} spillere -- runden har {total}." + return True, None + + if play_format == "shamble": + # Ett lag = HELE rundens deltakersett (bekreftet av bruker, INGEN + # round_side her) -- 2-4 spillere, fleksibel størrelse. + total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id) + if not (2 <= total <= 4): + return False, f"Shamble krever 2-4 spillere -- runden har {total}." + best_n = await conn.fetchval("SELECT shamble_best_n FROM round WHERE id = $1", round_id) + if best_n is None: + return False, "Velg hvor mange av lagets resultater som skal telle per hull (\"beste N av M\")." + if not (1 <= best_n <= total): + return False, f"\"Beste N av M\" må være mellom 1 og {total} -- er satt til {best_n}." + return True, None + + if play_format == "money_ball": + # Ett lag = HELE rundens deltakersett (samme struktur som Shamble), + # men FAST 4 -- ikke fleksibel størrelse. + total = await conn.fetchval("SELECT count(*) FROM round_participant WHERE round_id = $1", round_id) + if total != 4: + return False, f"Money Ball krever nøyaktig 4 spillere -- runden har {total}." + distinct_orders = await conn.fetchval( + "SELECT count(DISTINCT lineup_order) FROM round_participant WHERE round_id = $1 AND lineup_order IS NOT NULL", + round_id, + ) + if distinct_orders != 4: + return False, "Alle fire spillerne må ha en tildelt, distinkt plass i rekkefølgen (0-3)." + 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) @@ -575,8 +623,12 @@ 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"} +# kalles, ikke noe sted ellers. "chapman" tilføyd 2026-07-30 (Chapman/ +# Pinehurst/"Try all", strukturelt identisk med foursome -- se +# _ROUND_FORMAT_TO_ENGINE_FORMAT og app/handicap.py sin ENGINE_FORMAT_ALIASES). +_TWO_SIDED_FORMATS = { + "match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "chapman", "high_low_high", +} def _viewer_match_status_text(lead: int, holes_remaining: int, is_closed: bool) -> str: @@ -600,16 +652,36 @@ _ROUND_FORMAT_TO_ENGINE_FORMAT = { "greensome": "greensome", "scramble_2": "scramble_2", "scramble_4": "scramble_4", + # Chapman/Pinehurst ("Try all", 2026-07-30) -- delt ball, identisk med + # foursome (se app/handicap.py sin ENGINE_FORMAT_ALIASES for samme + # oppløsning på org-lagturnering-siden). + "chapman": "foursome", + # High-low-high (2026-07-30) -- 2 lag à 2, individuell ball, samme + # "alle fire samlet i én match_play_strokes-pool"-beregning som + # fourball (R&A-regelen), gjenbrukt uendret for selve HCP-delen -- + # selve poeng-rangeringen (high/low) er en HELT NY, egen beregning + # (se _build_high_low_high_result), ikke del av denne aliaseringen. + "high_low_high": "fourball", } # 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} +_SIDE_PLAYER_COUNT = { + "match": 1, "fourball": 2, "foursome": 2, "greensome": 2, "scramble_2": 2, "scramble_4": 4, + "chapman": 2, "high_low_high": 2, +} # 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 +# Københavner (2026-07-30) -- flatt felt, NØYAKTIG 3 (ikke "minst 3" som +# skins) -- 6-poengs-fordelingen (copenhagen_points_for_hole) er definert +# for nøyaktig 3 rangerte spillere, ikke et vilkårlig antall. +_COPENHAGEN_PLAYER_COUNT = 3 +# Bingo Bango Bongo (2026-07-30) -- flatt felt, minst 2 (med kun 1 spiller +# vinner de alle tre kategoriene hvert hull, meningsløst som konkurranse). +_BBB_MIN_PARTICIPANTS = 2 class RoundCreate(BaseModel): @@ -634,7 +706,8 @@ class RoundCreate(BaseModel): # 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", "stableford" + "stroke", "match", "skins", "fourball", "foursome", "greensome", "scramble_2", "scramble_4", "stableford", + "chapman", "copenhagen", "bbb", "flag", "shamble", "money_ball", "high_low_high", ] = "stroke" # Frontend forhåndsutfyller denne til True når play_format='match' # (anbefalt, men overstyrbart) -- eierens EGEN rad ved opprettelse. @@ -643,6 +716,12 @@ class RoundCreate(BaseModel): # 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 + # KUN meningsfullt når play_format='bbb' -- valgfri organisator- + # innstilling (bekreftet av bruker), ikke fast påslått. + bbb_sweep_bonus_enabled: bool = False + # KUN meningsfullt (og påkrevd) når play_format='shamble' -- antall av + # lagets individuelle resultater som teller per hull ("beste N av M"). + shamble_best_n: int | None = Field(default=None, ge=1) # ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts # innsyn (venner/offentligheten). Trygg standard 'private', samme # filosofi som RLS-policyenes "se ingenting" ved manglende kontekst. @@ -697,6 +776,8 @@ class RoundParticipantOut(BaseModel): # enheten (fra AllowanceStrategy), IKKE samme som course_handicap_ # snapshot (som forblir den absolutte, individuelle WHS-verdien). playing_handicap: int | None + # Money Ball (2026-07-30) -- 0-3, null for alle andre formater. + lineup_order: int | None class RoundSideOut(BaseModel): @@ -716,6 +797,10 @@ class RoundOut(BaseModel): play_format: str skins_scoring: str | None skins_tie_handling: str | None + # KUN meningsfullt for play_format='bbb' (2026-07-30). + bbb_sweep_bonus_enabled: bool + # KUN meningsfullt for play_format='shamble' (2026-07-30). + shamble_best_n: int | None started_at: str | None completed_at: str | None participants: list[RoundParticipantOut] @@ -764,7 +849,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: """ SELECT id::text AS id, course_source, name, course_name_snapshot, tee_name_snapshot, played_at, start_hole, holes_planned, play_format, skins_scoring, skins_tie_handling, - started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id + started_at, completed_at, visibility_mode, flight_group_id::text AS flight_group_id, + bbb_sweep_bonus_enabled, shamble_best_n FROM round WHERE id = $1 """, round_id, @@ -781,7 +867,7 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap + rp.playing_handicap, rp.lineup_order 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 @@ -869,6 +955,8 @@ async def _load_round_out(conn, round_id: str, viewer_user_id: str) -> RoundOut: play_format=round_row["play_format"], skins_scoring=round_row["skins_scoring"], skins_tie_handling=round_row["skins_tie_handling"], + bbb_sweep_bonus_enabled=round_row["bbb_sweep_bonus_enabled"], + shamble_best_n=round_row["shamble_best_n"], 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], @@ -1007,8 +1095,9 @@ async def create_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, - skins_scoring, skins_tie_handling, visibility_mode, flight_group_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + skins_scoring, skins_tie_handling, visibility_mode, flight_group_id, + bbb_sweep_bonus_enabled, shamble_best_n) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id::text AS id """, user.user_id, @@ -1028,6 +1117,8 @@ async def create_round( body.skins_tie_handling, body.visibility_mode, body.flight_group_id, + body.bbb_sweep_bonus_enabled, + body.shamble_best_n, ) round_id = round_row["id"] @@ -1037,7 +1128,8 @@ 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, + individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get(body.play_format, body.play_format) + not in SIDE_IS_UNIT, ) if body.visibility_mode == "friends" and body.visible_categories: @@ -1928,7 +2020,10 @@ async def add_participant( 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, + individual_round_hole=_ROUND_FORMAT_TO_ENGINE_FORMAT.get( + round_row["play_format"], 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"]) @@ -1956,7 +2051,7 @@ async def add_participant( rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap + rp.playing_handicap, rp.lineup_order FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 @@ -2088,12 +2183,17 @@ class ParticipantUpdate(BaseModel): # `null` for å fjerne fra en side. Eier-only (se _OWNER_ONLY_ # PARTICIPANT_FIELDS under). round_side_id: str | None = None + # Money Ball (2026-07-30) -- KUN meningsfullt for play_format= + # 'money_ball'. 0-3, unik per runde -- avgjør rotasjonsrekkefølgen + # ((posisjon-1) % 4) for hvem som er "money ball" på hvilket hull. + lineup_order: int | None = Field(default=None, ge=0, le=3) # 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", "round_side_id", + "lineup_order", } @@ -2182,6 +2282,20 @@ async def update_participant( values.append(new_side_id) set_clauses.append(f"round_side_id = ${len(values)}") + if "lineup_order" in updates: + new_lineup_order = updates["lineup_order"] + if new_lineup_order is not None: + if current["play_format"] != "money_ball": + raise app_error(400, "VALIDATION_FAILED", "Denne spilleformen bruker ikke lagrekkefølge.") + taken = await conn.fetchval( + "SELECT 1 FROM round_participant WHERE round_id = $1 AND lineup_order = $2 AND id != $3", + round_id, new_lineup_order, participant_id, + ) + if taken: + raise app_error(409, "DUPLICATE", "En annen spiller har allerede denne plassen i rekkefølgen.") + values.append(new_lineup_order) + set_clauses.append(f"lineup_order = ${len(values)}") + if rating_changed: if current["course_source"] == "teeoff": resolved = await _resolve_teeoff_course( @@ -2244,7 +2358,7 @@ async def update_participant( rp.course_handicap_snapshot, rp.counts_for_handicap, rp.score_differential::float AS score_differential, rp.stat_level, rp.exclude_from_handicap, rp.round_side_id::text AS round_side_id, - rp.playing_handicap + rp.playing_handicap, rp.lineup_order FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id WHERE rp.id = $1 @@ -2489,6 +2603,75 @@ async def update_side_hole( return RoundSideHoleOut(**dict(row)) +# --------------------------------------------------------------------------- +# Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-registrering (samme +# mønster som "valgt utslag" i scramble), ikke GPS/live-sporet. Egen liten +# tabell (round_bbb_hole) siden dette er en per-HULL-fakta, ikke per- +# spiller/side slik round_hole ellers er strukturert. +# --------------------------------------------------------------------------- + +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("/rounds/{round_id}/bbb/{hole_number}", response_model=BBBHoleOut) +async def update_bbb_hole( + round_id: str, + hole_number: int, + body: BBBHoleUpdate, + user: CurrentUser = Depends(get_current_user), +) -> BBBHoleOut: + async with plain_connection() as conn: + round_row = await conn.fetchrow( + "SELECT play_format, holes_planned FROM round WHERE id = $1", round_id + ) + if round_row is None: + raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") + if round_row["play_format"] != "bbb": + raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.") + if not (1 <= hole_number <= round_row["holes_planned"]): + raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.") + await _get_accessible_round_or_404(conn, round_id, user.user_id) + + 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 round_participant WHERE id = $1 AND round_id = $2", pid, round_id + ) + if not belongs: + raise app_error(400, "VALIDATION_FAILED", "Valgt spiller hører ikke til denne runden.") + + row = await conn.fetchrow( + """ + INSERT INTO round_bbb_hole + (round_id, hole_number, bingo_participant_id, bango_participant_id, bongo_participant_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (round_id, hole_number) DO UPDATE SET + bingo_participant_id = $3, bango_participant_id = $4, bongo_participant_id = $5 + 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 + """, + round_id, + hole_number, + body.bingo_participant_id, + body.bango_participant_id, + body.bongo_participant_id, + ) + await broadcast_round_update(round_id) + return BBBHoleOut(**dict(row)) + + # --------------------------------------------------------------------------- # Format-resultat (ADR-039) -- løpende matchstatus (match/fourball/foursome/ # greensome/scramble) eller skins-tavle, avledet ved LESING (aldri lagret), @@ -2497,6 +2680,15 @@ async def update_side_hole( # scorekort/leaderboard dekker det allerede. # --------------------------------------------------------------------------- +class FlagParticipantResult(BaseModel): + """Flaggturnering -- se handicap_engine.py sin flag_result().""" + + holes_completed: int + ran_out: bool + strokes_remaining: int + label: str + + class FormatHoleEntry(BaseModel): """Én "enhet" sitt tall på ett hull -- en deltaker for individuell-ball- formater (match/fourball/skins), eller en SIDE for delt-ball-formater @@ -2515,6 +2707,9 @@ class FormatHoleEntry(BaseModel): # tekst. Alltid sann for formater med kun én enhet per side (match, # foursome/greensome/scramble sin ene side-rad). counted: bool = True + # Københavner (2026-07-30) -- poeng denne deltakeren fikk PÅ DETTE + # hullet (0/1/2/3/4, summerer alltid til 6 blant de tre deltakerne). + points: int | None = None class FormatHoleOut(BaseModel): @@ -2540,6 +2735,26 @@ class RoundFormatResultOut(BaseModel): hole_results: list[str] | None = None # "a"/"b"/"halved" per avgjort hull, i spillerekkefølge # Skins: skins_won: dict[str, float] | None = None + # Københavner: totale poeng per deltaker (summerer til 6 * antall + # fullførte hull blant de tre deltakerne). + copenhagen_points: dict[str, int] | None = None + # Bingo Bango Bongo: totale poeng per deltaker. + bbb_points: dict[str, int] | None = None + # Flaggturnering: resultat per deltaker (None hvis course handicap + # ikke er klart ennå for den deltakeren). + flag_results: dict[str, FlagParticipantResult] | None = None + # Shamble: lagets (= hele rundens) samlede hullscore-sum -- kun basert + # på hull der ALLE spillerne har registrert, samme "vent til alle er + # ferdig"-prinsipp som resten av formatene. + shamble_team_score: int | None = None + # Money Ball: lagets (= hele rundens) samlede hullscore-sum. + money_ball_team_score: int | None = None + # High-low-high: literal poengsum per side (IKKE match-play "up"/ + # "down" -- se high_low_high_running_score). match_lead brukes likevel + # til fortegn/hvem-leder (positiv = side A), men disse to gir de + # FAKTISKE tallene til visning (f.eks. "2-1"). + hlh_points_a: int | None = None + hlh_points_b: int | None = None # Hull-for-hull-forløp (2026-07-28, "hvorfor ble resultatet som det ble" -- # brutto/netto/slag-mottatt per enhet, ikke bare et vinn/tap-merke). # None når ready=False (to-sidede formater) -- ellers alltid satt, @@ -2553,17 +2768,131 @@ def _round_hole_order(start_hole: int, holes_planned: int) -> list[int]: return [((start_hole - 1 + i) % 18) + 1 for i in range(holes_planned)] +async def _build_high_low_high_result(conn, round_id: str, round_row) -> RoundFormatResultOut: + """High-low-high (2026-07-30) -- passer IKKE inn i den generiske to- + siders-grenen under (som forutsetter ÉN sammenlignbar netto-verdi per + side per hull via side_net()) -- trenger BEGGE spilleres individuelle + Stableford-poeng per hull for selve høy/lav-rangeringen. Egen, + selvstendig sti, samme mønster som Shamble/Money Ball sine dedikerte + grener. Beregnes ved LESING, ingen cache-kolonne (som Nassau).""" + relative = await _relative_strokes_for_round(conn, round_id, "high_low_high") + if not relative: + return RoundFormatResultOut(play_format="high_low_high", ready=False) + + side_rows = await conn.fetch( + "SELECT id::text AS id, label FROM round_side WHERE round_id = $1 ORDER BY id", round_id + ) + if len(side_rows) != 2: + return RoundFormatResultOut(play_format="high_low_high", ready=False) + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.round_side_id::text AS side_id, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 AND rp.round_side_id IS NOT NULL + """, + round_id, + ) + by_side: dict[str, list[str]] = {} + for p in participant_rows: + by_side.setdefault(p["side_id"], []).append(p["id"]) + if any(len(by_side.get(s["id"], [])) != 2 for s in side_rows): + return RoundFormatResultOut(play_format="high_low_high", ready=False) + + hole_rows = await conn.fetch( + """ + SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, 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 + ORDER BY rh.hole_number + """, + round_id, + ) + all_18_si_by_hole: dict[int, int] = {} + par_by_hole: dict[int, int] = {} + gross_by_hole: dict[int, dict[str, int]] = {} + for r in hole_rows: + all_18_si_by_hole[r["hole_number"]] = r["stroke_index"] + par_by_hole[r["hole_number"]] = r["par"] + if r["played"] and r["score"] is not None: + gross_by_hole.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] + if len(all_18_si_by_hole) != 18: + return RoundFormatResultOut(play_format="high_low_high", ready=False) + all_18_si = [all_18_si_by_hole[h] for h in range(1, 19)] + + strokes_per_hole = { + pid: dict(zip(range(1, 19), allocate_over_played_holes(total, all_18_si, list(range(1, 19))))) + for pid, total in relative.items() + } + + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + side_a_id, side_b_id = sorted(by_side.keys()) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + + hole_points: list[tuple[int, int]] = [] + holes = [] + for h in play_order: + scores_this_hole = gross_by_hole.get(h, {}) + pids_a = by_side[side_a_id] + pids_b = by_side[side_b_id] + 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)) + entries = [ + FormatHoleEntry( + id=pid, + label=label_by_participant.get(pid, "Ukjent"), + side="a" if pid in pids_a else "b", + gross=scores_this_hole[pid], + strokes_received=strokes_per_hole[pid][h], + points=pts, + ) + for pid, pts in zip( + pids_a + pids_b, points_a_players + points_b_players + ) + ] + holes.append( + FormatHoleOut( + hole_number=h, par=par_by_hole[h], stroke_index=all_18_si_by_hole[h], entries=entries, + result=("a" if pa > pb else "b" if pb > pa else "halved"), + ) + ) + + total_a, total_b = high_low_high_running_score(hole_points) + return RoundFormatResultOut( + play_format="high_low_high", + ready=True, + match_lead=total_a - total_b, + match_holes_played=len(hole_points), + hlh_points_a=total_a, + hlh_points_b=total_b, + holes=holes, + ) + + async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut: """Delt mellom den autentiserte GET og den offentlige/venn-gatede /public/rounds-varianten (ADR-036 fase 2) -- selve dataen er identisk, kun autorisasjonssjekken FØR dette kalles skiller de to.""" round_row = await conn.fetchrow( - "SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned " - "FROM round WHERE id = $1", + "SELECT play_format, skins_scoring, skins_tie_handling, start_hole, holes_planned, " + "bbb_sweep_bonus_enabled, shamble_best_n FROM round WHERE id = $1", round_id, ) play_format = round_row["play_format"] + if play_format == "high_low_high": + return await _build_high_low_high_result(conn, round_id, round_row) + if play_format in _TWO_SIDED_FORMATS: relative = await _relative_strokes_for_round(conn, round_id, play_format) if not relative: @@ -2822,9 +3151,489 @@ async def _build_format_result(conn, round_id: str) -> RoundFormatResultOut: ) return RoundFormatResultOut(play_format=play_format, ready=True, skins_won=skins_won, holes=holes) + if play_format == "copenhagen": + # Individuell ball, INGEN sider (flatt felt av 3, ADR-011s + # to-lags-modell passer ikke) -- alltid NETTO (egen, individuell + # course handicap-fordeling, ikke match-play-relativ som match/ + # fourball -- Københavner har ingen "side" å være relativ TIL). + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.course_handicap_snapshot, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + hole_rows = await conn.fetch( + """ + SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, 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, + ) + by_participant_holes: dict[str, list] = {} + for r in hole_rows: + by_participant_holes.setdefault(r["participant_id"], []).append(r) + strokes_by_participant: dict[str, dict[int, int]] = {} + for p in participant_rows: + ch = p["course_handicap_snapshot"] + holes_for_participant = by_participant_holes.get(p["id"], []) + if ch is None or not holes_for_participant: + continue + allocation = allocate_strokes_by_index(ch, [h["stroke_index"] for h in holes_for_participant]) + strokes_by_participant[p["id"]] = { + h["hole_number"]: a for h, a in zip(holes_for_participant, allocation) + } + + gross_by_hole: dict[int, dict[str, int]] = {} + par_by_hole: dict[int, int] = {} + si_by_hole: dict[int, int] = {} + net_by_hole: dict[int, dict[str, int]] = {} + for r in hole_rows: + par_by_hole[r["hole_number"]] = r["par"] + si_by_hole[r["hole_number"]] = r["stroke_index"] + if not r["played"] or r["score"] is None: + continue + gross_by_hole.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] + per_hole = strokes_by_participant.get(r["participant_id"]) + if per_hole is None: + continue + net_by_hole.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] - per_hole[r["hole_number"]] + + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + scores_by_hole = [ + [(pid, net) for pid, net in net_by_hole.get(h, {}).items()] for h in play_order + ] + copenhagen_points, hole_log = compute_copenhagen_detail(scores_by_hole, higher_is_better=False) + + holes = [] + for hole_number, log_entry in zip(play_order, hole_log): + if not log_entry.values: + continue + gross_this_hole = gross_by_hole.get(hole_number, {}) + entries = [ + FormatHoleEntry( + id=pid, + label=label_by_participant.get(pid, "Ukjent"), + gross=gross_this_hole.get(pid), + net=net_value, + strokes_received=strokes_by_participant.get(pid, {}).get(hole_number), + points=log_entry.points.get(pid), + ) + for pid, net_value in log_entry.values.items() + ] + holes.append( + FormatHoleOut( + hole_number=hole_number, + par=par_by_hole[hole_number], + stroke_index=si_by_hole[hole_number], + entries=entries, + ) + ) + return RoundFormatResultOut( + play_format=play_format, ready=True, copenhagen_points=copenhagen_points, holes=holes + ) + + if play_format == "bbb": + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + # Individuell-ball round_hole-rader finnes (samme som stroke/skins/ + # copenhagen), men brukes ikke til poeng her -- kun til par/stroke- + # index-referanse for hull-visningen (identisk for alle deltakere). + par_si_rows = await conn.fetch( + """ + SELECT DISTINCT hole_number, par, stroke_index FROM round_hole + WHERE round_participant_id IN (SELECT id FROM round_participant WHERE round_id = $1) + """, + round_id, + ) + par_by_hole = {r["hole_number"]: r["par"] for r in par_si_rows} + si_by_hole = {r["hole_number"]: r["stroke_index"] for r in par_si_rows} + bbb_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 round_bbb_hole WHERE round_id = $1 ORDER BY hole_number + """, + round_id, + ) + sweep_bonus_enabled = round_row["bbb_sweep_bonus_enabled"] + hole_log = [ + (r["bingo_participant_id"], r["bango_participant_id"], r["bongo_participant_id"]) for r in bbb_rows + ] + bbb_points = compute_bbb(hole_log, sweep_bonus_enabled) + + holes = [] + for r, log_entry in zip(bbb_rows, hole_log): + bingo_id, bango_id, bongo_id = log_entry + if bingo_id is None and bango_id is None and bongo_id is None: + continue + hole_points = bbb_points_for_hole(bingo_id, bango_id, bongo_id, sweep_bonus_enabled) + entries = [ + FormatHoleEntry(id=pid, label=label_by_participant.get(pid, "Ukjent"), points=p) + for pid, p in hole_points.items() + ] + holes.append( + FormatHoleOut( + hole_number=r["hole_number"], + par=par_by_hole.get(r["hole_number"], 0), + stroke_index=si_by_hole.get(r["hole_number"], 0), + entries=entries, + ) + ) + return RoundFormatResultOut(play_format=play_format, ready=True, bbb_points=bbb_points, holes=holes) + + if play_format == "flag": + # Individuell ball, INGEN sider. Budsjett = sum(par for hele + # runden) + course_handicap_snapshot (samme "100% individuell + # allowance"-prinsipp som Slagspill individuelt i EGA-tabellen, + # ADR-005) -- IKKE playing_handicap (den kolonnen er kun for + # to-siders match-play-relative formater, se ADR-039 Beslutning B). + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.course_handicap_snapshot, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + flag_results: dict[str, FlagParticipantResult] = {} + for p in participant_rows: + if p["course_handicap_snapshot"] is None: + continue + hole_rows = await conn.fetch( + "SELECT hole_number, par, played, score FROM round_hole WHERE round_participant_id = $1", + p["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) + # SAMMENHENGENDE prefiks fra start_hole -- samme prinsipp som + # match-motorens hull-for-hull-forløp (aldri hull lenger ute i + # sekvensen selv om de tilfeldigvis er registrert). + gross_prefix = [] + for h in play_order: + row = by_hole.get(h) + if row is None or not row["played"] or row["score"] is None: + break + gross_prefix.append(row["score"]) + budget = total_par + p["course_handicap_snapshot"] + fr = flag_result(gross_prefix, budget) + flag_results[p["id"]] = FlagParticipantResult( + holes_completed=fr.holes_completed, + ran_out=fr.ran_out, + strokes_remaining=fr.strokes_remaining, + label=p["label"], + ) + return RoundFormatResultOut(play_format=play_format, ready=True, flag_results=flag_results) + + if play_format == "shamble": + # Individuell ball, INGEN sider -- laget ER hele rundens deltaker- + # sett (bekreftet av bruker). Rå brutto (ikke netto -- kilden + # nevner ingen handicap-justering for selve hullscoren, kun for + # HVEM som spiller på laget sammen, se ADR-039-lignende allowance + # et fremtidig steg kunne legge til). + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + best_n = round_row["shamble_best_n"] + hole_rows = await conn.fetch( + """ + SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, 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, + ) + by_hole: dict[int, dict[str, int]] = {} + par_by_hole: dict[int, int] = {} + si_by_hole: dict[int, int] = {} + for r in hole_rows: + par_by_hole[r["hole_number"]] = r["par"] + si_by_hole[r["hole_number"]] = r["stroke_index"] + if r["played"] and r["score"] is not None: + by_hole.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] + + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + expected_players = len(participant_rows) + team_total = 0 + any_hole_ready = False + holes = [] + for h in play_order: + scores_by_pid = by_hole.get(h, {}) + if len(scores_by_pid) != expected_players or best_n is None: + continue + hole_score = shamble_hole_score(list(scores_by_pid.values()), best_n) + team_total += hole_score + any_hole_ready = True + counted_pids = {pid for pid, _ in sorted(scores_by_pid.items(), key=lambda kv: kv[1])[:best_n]} + entries = [ + FormatHoleEntry( + id=pid, label=label_by_participant.get(pid, "Ukjent"), gross=score, counted=pid in counted_pids + ) + for pid, score in scores_by_pid.items() + ] + holes.append(FormatHoleOut(hole_number=h, par=par_by_hole[h], stroke_index=si_by_hole[h], entries=entries)) + return RoundFormatResultOut( + play_format=play_format, + ready=True, + shamble_team_score=team_total if any_hole_ready else None, + holes=holes, + ) + + if play_format == "money_ball": + # Ett lag = HELE rundens deltakersett (som Shamble), FAST 4. + # Rotasjonen er basert på POSISJON i spillerekkefølgen (1. spilte + # hull = rotasjonsindeks 0), IKKE rått hullnummer -- riktig også + # for en runde som starter et annet sted enn hull 1. + participant_rows = await conn.fetch( + """ + SELECT rp.id::text AS id, rp.lineup_order, + COALESCE(rp.guest_name, au.display_name, 'Medspiller') AS label + FROM round_participant rp LEFT JOIN app_user au ON au.id = rp.user_id + WHERE rp.round_id = $1 + """, + round_id, + ) + label_by_participant = {p["id"]: p["label"] for p in participant_rows} + ordered_pids = [p["id"] for p in sorted(participant_rows, key=lambda p: p["lineup_order"] or 0)] + if len(participant_rows) != 4 or any(p["lineup_order"] is None for p in participant_rows): + return RoundFormatResultOut(play_format=play_format, ready=False) + + hole_rows = await conn.fetch( + """ + SELECT rp.id::text AS participant_id, rh.hole_number, rh.par, 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, + ) + by_hole: dict[int, dict[str, int]] = {} + par_by_hole: dict[int, int] = {} + si_by_hole: dict[int, int] = {} + for r in hole_rows: + par_by_hole[r["hole_number"]] = r["par"] + si_by_hole[r["hole_number"]] = r["stroke_index"] + if r["played"] and r["score"] is not None: + by_hole.setdefault(r["hole_number"], {})[r["participant_id"]] = r["score"] + + play_order = _round_hole_order(round_row["start_hole"], round_row["holes_planned"]) + team_total = 0 + any_hole_ready = False + holes = [] + for position, h in enumerate(play_order, start=1): + scores_by_pid = by_hole.get(h, {}) + if len(scores_by_pid) != 4: + continue + scores_in_order = [scores_by_pid[pid] for pid in ordered_pids] + hole_score = money_ball_hole_score(scores_in_order, position) + team_total += hole_score + any_hole_ready = True + mb_pid = ordered_pids[(position - 1) % 4] + others_scores = [s for pid, s in scores_by_pid.items() if pid != mb_pid] + best_other = min(others_scores) if others_scores else None + entries = [ + FormatHoleEntry( + id=pid, + label=label_by_participant.get(pid, "Ukjent"), + gross=score, + counted=(pid == mb_pid) or (score == best_other), + ) + for pid, score in scores_by_pid.items() + ] + holes.append(FormatHoleOut(hole_number=h, par=par_by_hole[h], stroke_index=si_by_hole[h], entries=entries)) + return RoundFormatResultOut( + play_format=play_format, + ready=True, + money_ball_team_score=team_total if any_hole_ready else None, + holes=holes, + ) + return RoundFormatResultOut(play_format=play_format, ready=True) +# --------------------------------------------------------------------------- +# Nassau Match Play (2026-07-30) -- INGEN ny motor, INGEN lagring. Nassau er +# bare 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 uendret. Krever nøyaktig 18 hull (holes_planned==18) +# -- windows 1-9/10-18 gir ikke mening for en 9-hulls runde. +# +# Egen, selvstendig hull-resultat-bygger (ikke en refaktor av +# _build_format_result over) -- gjenbruker _relative_strokes_for_round +# (den kritiske, allerede testede handicap-delen), men bygger selve +# hole_results-listen på nytt her, isolert fra den komplekse, produksjons- +# kritiske _build_format_result-funksjonen for å unngå risiko ved å røre den. +# --------------------------------------------------------------------------- + +async def _two_sided_hole_results(conn, round_id: str, round_row) -> list[HoleResult] | None: + play_format = round_row["play_format"] + relative = await _relative_strokes_for_round(conn, round_id, play_format) + if not relative: + return None + + 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: + 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, + ) + unit_to_side = None + else: + rows = await conn.fetch( + """ + SELECT rp.id::text AS unit, rh.hole_number, rh.stroke_index, rh.played, rh.score, + rp.round_side_id::text AS side_id + 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, + ) + unit_to_side = {r["unit"]: r["side_id"] for r in rows} + + by_unit_hole: dict[tuple[str, int], list] = {} + si_by_hole: dict[int, int] = {} + for r in rows: + si_by_hole[r["hole_number"]] = r["stroke_index"] + if r["played"] and r["score"] is not None: + by_unit_hole.setdefault((r["unit"], r["hole_number"]), []).append(r["score"]) + + if len(si_by_hole) != 18: + return None + all_18_si = [si_by_hole[h] for h in range(1, 19)] + + 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() + } + + if engine_format in SIDE_IS_UNIT: + side_ids = sorted(relative.keys()) + else: + side_ids = sorted({s for s in unit_to_side.values() if s is not None}) + if len(side_ids) != 2: + return None + side_a_id, side_b_id = side_ids + + def units_for_side(side_id: str) -> list[str]: + if engine_format in SIDE_IS_UNIT: + return [side_id] + return [uid for uid, sid in unit_to_side.items() if sid == side_id] + + def side_net(hole_number: int, side_id: str) -> int | None: + # units_for_side returner ALLTID minst én enhet for en gyldig side -- + # løkken returnerer None tidlig hvis NOEN av dem mangler score, så + # `nets` er enten fullt populert (alle enheter klare) eller vi har + # allerede returnert None. Samme "vent til alle er ferdig"-prinsipp + # som _build_format_result sin egen side_net. + nets = [] + for unit_id in units_for_side(side_id): + 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 not nets: + 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) + return hole_results + + +class NassauWindowOut(BaseModel): + lead: int + holes_played: int + holes_remaining: int + is_closed: bool + is_dormie: bool + status_text: str + + +class RoundNassauResultOut(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("/rounds/{round_id}/format-result/nassau", response_model=RoundNassauResultOut) +async def get_round_nassau_result( + round_id: str, + user: CurrentUser = Depends(get_current_user), +) -> RoundNassauResultOut: + 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, start_hole, holes_planned FROM round WHERE id = $1", round_id + ) + if round_row["play_format"] not in _TWO_SIDED_FORMATS: + raise app_error(400, "VALIDATION_FAILED", "Nassau krever et to-siders spilleformat.") + if round_row["holes_planned"] != 18: + raise app_error(400, "VALIDATION_FAILED", "Nassau krever 18 hull.") + hole_results = await _two_sided_hole_results(conn, round_id, round_row) + if hole_results is None: + return RoundNassauResultOut(ready=False) + return RoundNassauResultOut( + 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), + ) + + @router.get("/rounds/{round_id}/format-result", response_model=RoundFormatResultOut) async def get_round_format_result( round_id: str, diff --git a/app/routers/scoring.py b/app/routers/scoring.py index 0e224b5..cc4603b 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -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 -- rå 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 på 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 -- rå brutto, INGEN + handicap-justering (samme begrunnelse som Shamble). Rotasjonen er + basert på 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) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 4702580..40601ba 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -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, diff --git a/handicap_engine.py b/handicap_engine.py index b2e510b..a321cf8 100644 --- a/handicap_engine.py +++ b/handicap_engine.py @@ -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 -- + rå/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 på 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 på 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 på 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 på hull i+1 (rotasjonsindeks = + (hole_number - 1) % 4 -- spiller 0 på hull 1, spiller 1 på hull 2, osv, + tilbake til spiller 0 på 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) diff --git a/test_handicap_engine.py b/test_handicap_engine.py index 76c1590..cfd0cb3 100644 --- a/test_handicap_engine.py +++ b/test_handicap_engine.py @@ -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