All eight new turneringsformater have backend + engine complete and scratch-verified — Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, and High-low-high. Built and tested one format at a time (engine tests → migration → API wiring → scratch verification) exactly per the approved plan:
Verification totals: handicap_engine.py unit tests went from 63 → 98 (all passing), plus 400+ scratch-API checks across the eight formats' test scripts, covering both frittstående runder and the appropriate org-scoped system (team or individual) for each. test_isolation.sql stayed 12/12 throughout — migrations 041–050 are purely additive.
A few real things surfaced and were fixed during testing:
Nassau can be blocked by the existing "match already decided" lock (ADR-012) if the overall 18-hole match closes early — documented as a known v1 limitation, not fixed (would mean loosening an established safety guard).
Københavner's points depend on all 3 players at once, breaking the usual "recompute one participant" pattern — handled with a dedicated recompute path.
High-low-high doesn't fit the existing win/loss/halved match cache, so it gets its own dedicated read endpoint (like Nassau) — verified digit-for-digit against your own worked example ("1-1 etter hull 1", "2-1 til lag 2").
Two missing-column crashes (caught in scratch, never reached anything resembling "done").
This commit is contained in:
parent
facefd9951
commit
7ae554062c
21 changed files with 2529 additions and 43 deletions
25
041_chapman_format.sql
Normal file
25
041_chapman_format.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Chapman/Pinehurst ("Try all"), migrasjon 041. Første av åtte
|
||||
-- nye turneringsformater denne runden (se plan/ADR for full liste og
|
||||
-- begrunnelse). Chapman er strukturelt IDENTISK med eksisterende
|
||||
-- foursome (delt ball, én score per hull per side, CombinedPercentage
|
||||
-- 50%) -- reglene om hvem som slår hvilket slag (bytt utslag, velg
|
||||
-- beste 2. slag, spilleren som ikke ble valgt slår 3. slag, deretter
|
||||
-- alternerer de) er ren gameplay-etikette som foursome heller ikke
|
||||
-- sporer noe sted -- ingen ny motorlogikk, kun et nytt formatnavn som
|
||||
-- ALIASES til foursome (se app/handicap.py sin nye
|
||||
-- ENGINE_FORMAT_ALIASES og rounds.py sin _ROUND_FORMAT_TO_ENGINE_FORMAT).
|
||||
--
|
||||
-- To separate CHECK-utvidelser: round.play_format (frittstående runder)
|
||||
-- OG session.format (org-lagturneringer) -- Chapman er ALDRI aktuelt for
|
||||
-- den individuelle org-modellen (ADR-037), kun to-siders formater.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman'));
|
||||
|
||||
ALTER TABLE session DROP CONSTRAINT session_format_check;
|
||||
ALTER TABLE session ADD CONSTRAINT session_format_check
|
||||
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman'));
|
||||
30
042_copenhagen_round_and_tournament.sql
Normal file
30
042_copenhagen_round_and_tournament.sql
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Københavner/Copenhagen, migrasjon 042. Andre av åtte nye
|
||||
-- turneringsformater denne runden (se plan/ADR for full liste).
|
||||
--
|
||||
-- Flatt felt, nøyaktig 3 spillere, INGEN sider -- passer ikke ADR-011s
|
||||
-- to-lags-modell (bekreftet av ADR-011s eget 2026-07-19-notat). Hører
|
||||
-- derfor hjemme i frittstående runder (round.play_format) OG den
|
||||
-- individuelle org-turnering-modellen (ADR-037, tournament.scoring_
|
||||
-- method) -- ALDRI org-lagturneringer.
|
||||
--
|
||||
-- 6 poeng per hull fordelt etter relativ rangering (se handicap_engine.py
|
||||
-- sin copenhagen_points_for_hole -- kilde: spilletyper-og-spilleformer-
|
||||
-- 2023.pdf s.4). Frittstående runder er individuell-ball (som stroke/
|
||||
-- stableford), beregnes helt ved lesing fra eksisterende round_hole --
|
||||
-- INGEN nye kolonner der. Den individuelle org-modellen cacher derimot
|
||||
-- et ferdig utregnet resultat per deltaker (samme mønster som gross_
|
||||
-- total/net_total/stableford_points i tournament_round_score, migrasjon
|
||||
-- 040) -- én ny kolonne.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen'));
|
||||
|
||||
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
|
||||
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
|
||||
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen'));
|
||||
|
||||
ALTER TABLE tournament_round_score ADD COLUMN copenhagen_points smallint;
|
||||
34
043_bbb_round_format.sql
Normal file
34
043_bbb_round_format.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Bingo Bango Bongo (frittstående runder), migrasjon 043.
|
||||
-- Fjerde av åtte nye turneringsformater denne runden (se plan/ADR for
|
||||
-- full liste). Flatt felt, INGEN sider, poengbasert. Bingo/bango/bongo
|
||||
-- er en per-HULL-fakta (hvem vant hver av de tre kategoriene), ikke en
|
||||
-- per-spiller/side-fakta -- passer derfor IKKE inn i round_hole sin
|
||||
-- eksisterende XOR-form (round_participant_id ELLER round_side_id) --
|
||||
-- krever en ny, liten dedikert tabell, én rad per hull for hele runden.
|
||||
--
|
||||
-- Alle tre kategoriene registreres MANUELT av den som fører score, samme
|
||||
-- mønster som "valgt utslag" i scramble/greensome (migrasjon 036) -- INGEN
|
||||
-- GPS/live-sporing. Sweep-bonus (dobbelt poeng ved rent sveip) er en
|
||||
-- valgfri organisator-innstilling (bekreftet av bruker), IKKE fast på.
|
||||
-- Lagvariant bevisst UTENFOR omfang v1 (bekreftet av bruker).
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb'));
|
||||
|
||||
ALTER TABLE round ADD COLUMN bbb_sweep_bonus_enabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
CREATE TABLE round_bbb_hole (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
round_id uuid NOT NULL REFERENCES round(id) ON DELETE CASCADE,
|
||||
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
|
||||
bingo_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
|
||||
bango_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
|
||||
bongo_participant_id uuid REFERENCES round_participant(id) ON DELETE SET NULL,
|
||||
UNIQUE (round_id, hole_number)
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON round_bbb_hole TO teecup_app;
|
||||
48
044_bbb_individual_tournament.sql
Normal file
48
044_bbb_individual_tournament.sql
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Bingo Bango Bongo (org individuell turnering), migrasjon 044.
|
||||
-- Samme begrunnelse som 043 (frittstående runder) -- se den migrasjonens
|
||||
-- header. Denne dekker den parallelle, RLS-beskyttede org-modellen
|
||||
-- (ADR-037): tournament.scoring_method='bingo_bango_bongo' + en ny
|
||||
-- dedikert tabell tournament_round_bbb_hole (samme composite-FK-/RLS-
|
||||
-- mønster som resten av migrasjon 040).
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
|
||||
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
|
||||
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo'));
|
||||
|
||||
ALTER TABLE tournament ADD COLUMN bbb_sweep_bonus_enabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Cachet totalsum per deltaker per runde (samme mønster som gross_total/
|
||||
-- net_total/stableford_points/copenhagen_points -- SQL summerer bare det
|
||||
-- cachede ved lesing i leaderboardet, motoren regner på nytt ved hver
|
||||
-- hull-innsending).
|
||||
ALTER TABLE tournament_round_score ADD COLUMN bbb_points smallint;
|
||||
|
||||
CREATE TABLE tournament_round_bbb_hole (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id uuid NOT NULL,
|
||||
tournament_round_id uuid NOT NULL,
|
||||
hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18),
|
||||
bingo_participant_id uuid,
|
||||
bango_participant_id uuid,
|
||||
bongo_participant_id uuid,
|
||||
FOREIGN KEY (organization_id, tournament_round_id)
|
||||
REFERENCES tournament_round(organization_id, id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (organization_id, bingo_participant_id)
|
||||
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (organization_id, bango_participant_id)
|
||||
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (organization_id, bongo_participant_id)
|
||||
REFERENCES tournament_participant(organization_id, id) ON DELETE SET NULL,
|
||||
UNIQUE (tournament_round_id, hole_number)
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON tournament_round_bbb_hole TO teecup_app;
|
||||
|
||||
ALTER TABLE tournament_round_bbb_hole ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tournament_round_bbb_hole FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY org_isolation ON tournament_round_bbb_hole
|
||||
USING (organization_id = current_setting('app.current_org', true)::uuid)
|
||||
WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid);
|
||||
19
045_flag_round_format.sql
Normal file
19
045_flag_round_format.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Flaggturnering/Flag tournament (frittstående runder),
|
||||
-- migrasjon 045. Femte av åtte nye turneringsformater denne runden.
|
||||
--
|
||||
-- Flatt felt, individuelt, INGEN sider. Gjenbruker EKSISTERENDE
|
||||
-- individuell-ball round_hole-lagring og round_participant.course_
|
||||
-- handicap_snapshot (allerede beregnet ved deltaker-opprettelse for
|
||||
-- ALLE formater, se _create_participant) -- INGEN nye kolonner. Par +
|
||||
-- course handicap gir et fast slag-budsjett for runden; flagget plantes
|
||||
-- der spilleren går tom (handicap_engine.py sin flag_result).
|
||||
--
|
||||
-- v1: hull-granularitet (ikke sub-hull/GPS -- bevisst utenfor omfang),
|
||||
-- ingen fortsettelse på hull 19+.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag'));
|
||||
12
046_flag_individual_tournament.sql
Normal file
12
046_flag_individual_tournament.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Flaggturnering/Flag tournament (org individuell turnering),
|
||||
-- migrasjon 046. Samme begrunnelse som 045 -- se den migrasjonens
|
||||
-- header. Gjenbruker eksisterende tournament_round_participant.course_
|
||||
-- handicap (allerede beregnet av _compute_round_participant_handicap
|
||||
-- for ALLE scoring_method-verdier) -- INGEN nye kolonner.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE tournament DROP CONSTRAINT tournament_scoring_method_check;
|
||||
ALTER TABLE tournament ADD CONSTRAINT tournament_scoring_method_check
|
||||
CHECK (scoring_method IS NULL OR scoring_method IN ('stroke_gross', 'stroke_net', 'stableford', 'copenhagen', 'bingo_bango_bongo', 'flag'));
|
||||
31
047_shamble_round_and_session.sql
Normal file
31
047_shamble_round_and_session.sql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Shamble, migrasjon 047. Sjette av åtte nye turneringsformater
|
||||
-- denne runden.
|
||||
--
|
||||
-- 2-4-manns lag (FLEKSIBEL størrelse, bekreftet av bruker -- ikke fast
|
||||
-- som de andre formatene). Lagets hullscore = sum av de N beste
|
||||
-- individuelle resultatene ("beste N av M", N organisator-konfigurerbart
|
||||
-- via shamble_best_n).
|
||||
--
|
||||
-- Frittstående runder: INGEN round_side-involvering (bekreftet av
|
||||
-- bruker) -- laget ER runden sitt fulle deltakersett, ingen motstander
|
||||
-- innad. Flere lag som konkurrerer settes opp som separate runder koblet
|
||||
-- med det allerede byggede flight_group_id-leaderboardet (2026-07-28).
|
||||
-- Individuell-ball round_hole (som fourball), bare variabel lagstørrelse
|
||||
-- i stedet for en fast FORMAT_UNIT_SIZE-verdi.
|
||||
--
|
||||
-- Org-lagturnering: HER brukes eksisterende to-siders match-struktur (to
|
||||
-- Shamble-lag møtes) -- variabel lagstørrelse (2-4) i stedet for fast
|
||||
-- antall.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble'));
|
||||
ALTER TABLE round ADD COLUMN shamble_best_n smallint CHECK (shamble_best_n IS NULL OR shamble_best_n >= 1);
|
||||
|
||||
ALTER TABLE session DROP CONSTRAINT session_format_check;
|
||||
ALTER TABLE session ADD CONSTRAINT session_format_check
|
||||
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble'));
|
||||
ALTER TABLE session ADD COLUMN shamble_best_n smallint CHECK (shamble_best_n IS NULL OR shamble_best_n >= 1);
|
||||
21
048_money_ball_round.sql
Normal file
21
048_money_ball_round.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Money Ball/Lone Ranger (frittstående runder), migrasjon 048.
|
||||
-- Sjuende av åtte nye turneringsformater denne runden.
|
||||
--
|
||||
-- Nøyaktig 4-manns lag. Samme "ett lag = hele runden, INGEN round_side"
|
||||
-- -struktur som Shamble (bekreftet av bruker) -- flere lag som
|
||||
-- konkurrerer settes opp som separate runder + flight_group_id.
|
||||
--
|
||||
-- Trenger en FAST spillerrekkefølge (round_participant.lineup_order,
|
||||
-- 0-3) for å vite hvem som er "money ball" på hvilket hull -- et
|
||||
-- konsept som ikke fantes noe sted i skjemaet fra før.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball'));
|
||||
|
||||
ALTER TABLE round_participant ADD COLUMN lineup_order smallint CHECK (lineup_order IS NULL OR lineup_order BETWEEN 0 AND 3);
|
||||
CREATE UNIQUE INDEX round_participant_lineup_order_unique
|
||||
ON round_participant (round_id, lineup_order) WHERE lineup_order IS NOT NULL;
|
||||
17
049_money_ball_session.sql
Normal file
17
049_money_ball_session.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — Money Ball/Lone Ranger (org-lagturnering), migrasjon 049.
|
||||
-- Samme begrunnelse som 048 -- se den migrasjonens header. HER brukes
|
||||
-- eksisterende to-siders match-struktur (to Money Ball-lag à 4 møtes).
|
||||
-- lineup_order plasseres på match_participant (ikke team_roster) siden
|
||||
-- rekkefølgen er meningsfull PER MATCH, samme prinsipp som team_side
|
||||
-- selv er match-scopet, ikke roster-scopet.
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE session DROP CONSTRAINT session_format_check;
|
||||
ALTER TABLE session ADD CONSTRAINT session_format_check
|
||||
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble', 'money_ball'));
|
||||
|
||||
ALTER TABLE match_participant ADD COLUMN lineup_order smallint CHECK (lineup_order IS NULL OR lineup_order BETWEEN 0 AND 3);
|
||||
CREATE UNIQUE INDEX match_participant_lineup_order_unique
|
||||
ON match_participant (match_id, team_side, lineup_order) WHERE lineup_order IS NOT NULL;
|
||||
20
050_high_low_high_round_and_session.sql
Normal file
20
050_high_low_high_round_and_session.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — High-low-high, migrasjon 050. Siste (åttende) av de nye
|
||||
-- turneringsformatene denne runden.
|
||||
--
|
||||
-- 2 lag à 2 spillere -- strukturelt identisk med fourball (individuell
|
||||
-- ball, round_hole/hole_score keyet per SPILLER, ikke per side). INGEN
|
||||
-- nye kolonner -- per-hull-rangeringen (høy/lav) og poengtellingen
|
||||
-- regnes helt ved LESING (handicap_engine.py sin high_low_high_points_
|
||||
-- for_hole/high_low_high_running_score), ingen cache-kolonne (samme
|
||||
-- "regn ut ved lesing"-filosofi som Nassau).
|
||||
-- =====================================================================
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
ALTER TABLE round DROP CONSTRAINT round_play_format_check;
|
||||
ALTER TABLE round ADD CONSTRAINT round_play_format_check
|
||||
CHECK (play_format IN ('stroke', 'match', 'skins', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'stableford', 'chapman', 'copenhagen', 'bbb', 'flag', 'shamble', 'money_ball', 'high_low_high'));
|
||||
|
||||
ALTER TABLE session DROP CONSTRAINT session_format_check;
|
||||
ALTER TABLE session ADD CONSTRAINT session_format_check
|
||||
CHECK (format IN ('singles', 'fourball', 'foursome', 'greensome', 'scramble_2', 'scramble_4', 'chapman', 'shamble', 'money_ball', 'high_low_high'));
|
||||
52
CLAUDE.md
52
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -42,6 +42,42 @@ FORMAT_UNIT_SIZE: dict[str, int] = {
|
|||
"scramble_4": 4,
|
||||
}
|
||||
|
||||
# Nye formater denne runden (2026-07-30) som er strukturelt IDENTISKE med et
|
||||
# eksisterende format -- ingen egen HCP-/allowance-logikk, bare et annet
|
||||
# selvdeklarert navn. Løses opp HELT FØRST i parse_allowance_config/
|
||||
# compute_and_store_side_handicaps/relative_strokes_for_match, slik at resten
|
||||
# av disse funksjonene (og SIDE_IS_UNIT/FORMAT_UNIT_SIZE/Format-enumet)
|
||||
# aldri ser aliasnavnet. Chapman/Pinehurst ("Try all", bekreftet av bruker):
|
||||
# delt ball, én score per hull per side, CombinedPercentage 50% -- identisk
|
||||
# med foursome, kun regler for HVEM som slår hvilket slag (ikke sporet i
|
||||
# skjemaet, verken her eller for foursome).
|
||||
ENGINE_FORMAT_ALIASES: dict[str, str] = {
|
||||
"chapman": "foursome",
|
||||
# Shamble (2026-07-30) -- variabel lagstørrelse (2-4), enheten er
|
||||
# SPILLEREN (som fourball/singles), ikke siden -- "singles" sin
|
||||
# PerPlayerPercentage(1.00) er telle-agnostisk (regner ett tall per
|
||||
# spiller uavhengig av hvor mange andre er på laget), i motsetning til
|
||||
# foursome/greensome/scramble sine faste antalls-strategier.
|
||||
# Kildens hullscore-regel (beste N av M) bruker rå brutto, ikke HCP-
|
||||
# justert netto -- course_handicap/playing_handicap beregnes og lagres
|
||||
# likevel her (for visning/et fremtidig steg), men brukes IKKE av
|
||||
# selve hullscore-utregningen (se scoring.py sin _side_net shamble-gren).
|
||||
"shamble": "singles",
|
||||
# Money Ball/Lone Ranger (2026-07-30) -- FAST 4-manns lag, enheten er
|
||||
# SPILLEREN (som Shamble/fourball), ikke siden. Rå brutto brukes for
|
||||
# selve hullscoren (se scoring.py sin _compute_money_ball_hole_results),
|
||||
# course_handicap/playing_handicap beregnes og lagres likevel for
|
||||
# visning (samme begrunnelse som Shamble).
|
||||
"money_ball": "singles",
|
||||
# High-low-high (2026-07-30) -- 2 lag à 2, individuell ball. course_
|
||||
# handicap/playing_handicap beregnes som fourball (samme "alle fire
|
||||
# samlet i én match_play_strokes-pool"-regel, R&A) -- selve poeng-
|
||||
# rangeringen (high/low per hull) er en HELT NY, egen beregning (se
|
||||
# app/routers/rounds.py sin _build_high_low_high_result), ikke del av
|
||||
# denne aliaseringen.
|
||||
"high_low_high": "fourball",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AllowanceConfig:
|
||||
|
|
@ -68,6 +104,7 @@ def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy:
|
|||
|
||||
def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig:
|
||||
"""ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard."""
|
||||
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
|
||||
override = allowance_override or {}
|
||||
return AllowanceConfig(
|
||||
use_handicap=override.get("use_handicap", True),
|
||||
|
|
@ -115,6 +152,7 @@ async def compute_and_store_side_handicaps(
|
|||
den validerer ikke selv (samme mønster som for manglende
|
||||
handicap_index_snapshot).
|
||||
"""
|
||||
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
|
||||
participants = await conn.fetch(
|
||||
"""
|
||||
SELECT mp.id::text AS id,
|
||||
|
|
@ -196,6 +234,7 @@ async def relative_strokes_for_match(
|
|||
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
|
||||
"kan ikke avgjøre noen hull ennå".
|
||||
"""
|
||||
format_ = ENGINE_FORMAT_ALIASES.get(format_, format_)
|
||||
if format_ in SIDE_IS_UNIT:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ class MatchParticipantOut(BaseModel):
|
|||
tee_id: str
|
||||
tee_name: str
|
||||
playing_handicap: int | None = None
|
||||
# Money Ball (2026-07-30) -- 0-3, null for alle andre formater.
|
||||
lineup_order: int | None = None
|
||||
|
||||
|
||||
class MatchCreate(BaseModel):
|
||||
|
|
@ -173,7 +175,7 @@ async def fetch_matches(
|
|||
"""
|
||||
SELECT mp.match_id::text AS match_id, mp.id::text, mp.team_side::text,
|
||||
mp.team_roster_id::text, p.display_name AS player_name,
|
||||
mp.tee_id::text, tee.name AS tee_name, mp.playing_handicap
|
||||
mp.tee_id::text, tee.name AS tee_name, mp.playing_handicap, mp.lineup_order
|
||||
FROM match_participant mp
|
||||
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
||||
JOIN player p ON p.id = tr.player_id
|
||||
|
|
@ -197,6 +199,7 @@ async def fetch_matches(
|
|||
tee_id=r["tee_id"],
|
||||
tee_name=r["tee_name"],
|
||||
playing_handicap=r["playing_handicap"],
|
||||
lineup_order=r["lineup_order"],
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -247,6 +250,9 @@ class ParticipantCreate(BaseModel):
|
|||
team_side: str = Field(pattern="^[ab]$")
|
||||
team_roster_id: str
|
||||
tee_id: str
|
||||
# Money Ball (2026-07-30) -- KUN meningsfullt for session.format=
|
||||
# 'money_ball'. 0-3, unik per (match, side).
|
||||
lineup_order: int | None = Field(default=None, ge=0, le=3)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -303,6 +309,45 @@ async def add_participant(
|
|||
403, "NOT_TEAM_CAPTAIN", "Du må være kaptein for laget (eller organisasjonsadministrator) for å gjøre dette."
|
||||
)
|
||||
|
||||
# Shamble (2026-07-30): variabel lagstørrelse (2-4), IKKE en fast
|
||||
# FORMAT_UNIT_SIZE -- compute_and_store_side_handicaps sin vanlige
|
||||
# SIDE_IS_UNIT-kapasitetssjekk gjelder ikke her (Shamble aliaser til
|
||||
# "singles", individuell enhet, telle-agnostisk). Håndhever derfor
|
||||
# kun et TAK her (min-2-sjekk finnes bevisst ikke ennå for org-
|
||||
# lagturneringer -- ingen tilsvarende "er oppsettet komplett"-gate
|
||||
# finnes for individuelle formater der i dag, samme som fourball).
|
||||
if session["format"] == "shamble":
|
||||
side_count = await conn.fetchval(
|
||||
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
|
||||
match_id,
|
||||
body.team_side,
|
||||
)
|
||||
if side_count >= 4:
|
||||
raise app_error(409, "LIMIT_REACHED", "Et Shamble-lag kan maks ha 4 spillere.")
|
||||
|
||||
# Money Ball (2026-07-30): FAST 4 -- ikke fleksibel som Shamble.
|
||||
# lineup_order (0-3) er PÅKREVD her (avgjør rotasjonen), unik per
|
||||
# (match, side) -- håndhevet eksplisitt FØR innsetting, ikke kun
|
||||
# via DB-constrainten, for en tydelig feilmelding.
|
||||
if session["format"] == "money_ball":
|
||||
side_count = await conn.fetchval(
|
||||
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
|
||||
match_id,
|
||||
body.team_side,
|
||||
)
|
||||
if side_count >= 4:
|
||||
raise app_error(409, "LIMIT_REACHED", "Et Money Ball-lag kan maks ha 4 spillere.")
|
||||
if body.lineup_order is None:
|
||||
raise app_error(400, "VALIDATION_FAILED", "lineup_order er påkrevd for Money Ball.")
|
||||
taken = await conn.fetchval(
|
||||
"SELECT 1 FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side AND lineup_order = $3",
|
||||
match_id,
|
||||
body.team_side,
|
||||
body.lineup_order,
|
||||
)
|
||||
if taken:
|
||||
raise app_error(409, "DUPLICATE", "En annen spiller på laget har allerede denne plassen i rekkefølgen.")
|
||||
|
||||
locked = await locked_team_ids(conn, match["session_id"])
|
||||
if expected_team_id in locked:
|
||||
raise app_error(
|
||||
|
|
@ -370,13 +415,13 @@ async def add_participant(
|
|||
"""
|
||||
WITH inserted AS (
|
||||
INSERT INTO match_participant
|
||||
(organization_id, match_id, team_side, team_roster_id, tee_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, team_side, team_roster_id, tee_id
|
||||
(organization_id, match_id, team_side, team_roster_id, tee_id, lineup_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, team_side, team_roster_id, tee_id, lineup_order
|
||||
)
|
||||
SELECT inserted.id::text, inserted.team_side::text,
|
||||
inserted.team_roster_id::text, p.display_name AS player_name,
|
||||
inserted.tee_id::text, tee.name AS tee_name
|
||||
inserted.tee_id::text, tee.name AS tee_name, inserted.lineup_order
|
||||
FROM inserted
|
||||
JOIN team_roster tr ON tr.id = inserted.team_roster_id
|
||||
JOIN player p ON p.id = tr.player_id
|
||||
|
|
@ -387,6 +432,7 @@ async def add_participant(
|
|||
body.team_side,
|
||||
body.team_roster_id,
|
||||
body.tee_id,
|
||||
body.lineup_order,
|
||||
)
|
||||
|
||||
# Beregn/lagre course_handicap/playing_handicap nå som deltakeren er
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue