2026-07-16 07:18:01 +02:00
|
|
|
|
"""
|
|
|
|
|
|
TeeCup handicap-motor
|
|
|
|
|
|
=====================
|
|
|
|
|
|
|
|
|
|
|
|
Frittstående regel- og matematikk-motor for golfhandicap (ADR-005).
|
|
|
|
|
|
Ingen avhengigheter til database, API eller web-rammeverk — kun standardbibliotek.
|
|
|
|
|
|
|
|
|
|
|
|
Designprinsipp (ADR-005):
|
|
|
|
|
|
"Allowances" (prosenttildelinger per format) er KONFIGURASJON, ikke hardkodet
|
|
|
|
|
|
logikk. Motoren vet ikke selv at four-ball = 90 %; den mottar en
|
|
|
|
|
|
AllowanceStrategy. Standardverdiene under følger R&A Appendix C (2026), men
|
|
|
|
|
|
kan overstyres per turnering — f.eks. 75 %, 3/4, eller lokale varianter.
|
|
|
|
|
|
|
|
|
|
|
|
Kilder for standardverdier: R&A Rules of Handicapping, Appendix C.
|
|
|
|
|
|
- Course Handicap = Index × Slope/113 + (Course Rating − Par)
|
|
|
|
|
|
- Allowance påføres UAVRUNDET Course Handicap; resultatet avrundes til
|
|
|
|
|
|
Playing Handicap.
|
|
|
|
|
|
- Match play: laveste enhet spiller av 0, øvrige mottar differansen.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from dataclasses import dataclass
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
from datetime import date, timedelta
|
2026-07-16 07:18:01 +02:00
|
|
|
|
from decimal import Decimal, ROUND_FLOOR
|
|
|
|
|
|
from enum import Enum
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
|
from typing import Literal, Protocol, Sequence
|
2026-07-16 07:18:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Grunnleggende matematikk
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def round_half_up(value: float) -> int:
|
|
|
|
|
|
"""Avrund til nærmeste heltall, 0,5 alltid opp mot mer positivt tall.
|
|
|
|
|
|
|
|
|
|
|
|
Pythons innebygde round() bruker "banker's rounding" (0,5 -> nærmeste
|
|
|
|
|
|
partall), noe som gir feil handicap. WHS bruker vanlig 0,5-opp.
|
|
|
|
|
|
|
|
|
|
|
|
Merk om minus-handicap (plusspillere): "opp" tolkes her som mot +uendelig,
|
|
|
|
|
|
slik at -2,5 -> -2 (ikke -3). Dette er en sjelden kant og regelverket er
|
|
|
|
|
|
ikke entydig; endre bare denne funksjonen dersom en autoritativ kilde
|
|
|
|
|
|
tilsier noe annet. Bruk Decimal for å unngå flyttallsfeil.
|
|
|
|
|
|
"""
|
|
|
|
|
|
d = Decimal(str(value))
|
|
|
|
|
|
floor_part = d.to_integral_value(rounding=ROUND_FLOOR)
|
|
|
|
|
|
frac = d - floor_part
|
|
|
|
|
|
if frac >= Decimal("0.5"):
|
|
|
|
|
|
return int(floor_part) + 1
|
|
|
|
|
|
return int(floor_part)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def course_handicap_raw(
|
|
|
|
|
|
handicap_index: float,
|
|
|
|
|
|
slope_rating: float,
|
|
|
|
|
|
course_rating: float,
|
|
|
|
|
|
par: int,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Uavrundet Course Handicap.
|
|
|
|
|
|
|
|
|
|
|
|
Index × Slope/113 + (Course Rating − Par). Beholdes uavrundet fordi
|
|
|
|
|
|
allowancen skal påføres den uavrundede verdien (Appendix C).
|
|
|
|
|
|
"""
|
|
|
|
|
|
return handicap_index * (slope_rating / 113.0) + (course_rating - par)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def course_handicap(
|
|
|
|
|
|
handicap_index: float,
|
|
|
|
|
|
slope_rating: float,
|
|
|
|
|
|
course_rating: float,
|
|
|
|
|
|
par: int,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Avrundet Course Handicap (til referanse/visning)."""
|
|
|
|
|
|
return round_half_up(course_handicap_raw(handicap_index, slope_rating, course_rating, par))
|
|
|
|
|
|
|
|
|
|
|
|
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
def round_half_up_decimal(value: float, ndigits: int) -> float:
|
|
|
|
|
|
"""Som `round_half_up`, men til `ndigits` desimaler i stedet for heltall.
|
|
|
|
|
|
|
|
|
|
|
|
Brukt for Score Differential og Handicap Index, som begge rundes til
|
|
|
|
|
|
nærmeste tidel med ,5 alltid oppover mot mer positivt tall — inkludert
|
|
|
|
|
|
for negative verdier (Rule 5.1c: -1,54 -> -1,5, -1,55 -> -1,5,
|
|
|
|
|
|
-1,56 -> -1,6 — verifisert mot alle tre eksemplene i regelboken).
|
|
|
|
|
|
"""
|
|
|
|
|
|
quant = Decimal(1).scaleb(-ndigits)
|
|
|
|
|
|
scaled = Decimal(str(value)) / quant
|
|
|
|
|
|
floor_part = scaled.to_integral_value(rounding=ROUND_FLOOR)
|
|
|
|
|
|
frac = scaled - floor_part
|
|
|
|
|
|
rounded_scaled = floor_part + 1 if frac >= Decimal("0.5") else floor_part
|
|
|
|
|
|
return float(rounded_scaled * quant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# HCP-indeksberegning fra spilte runder (ADR-033, frittstående rundeføring)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# Kilde: WHS Rules of Handicapping, effektiv januar 2024 (USGA/R&A) — lastet
|
|
|
|
|
|
# opp av bruker 2026-07-22, lest i sin helhet. Alle formler/tall under er
|
|
|
|
|
|
# hentet direkte derfra, ikke antatt. Se ADR-033 for full sporing av hvert
|
|
|
|
|
|
# valg, inkl. hvor kilden var taus (Expected Score, PCC — se avvik under).
|
|
|
|
|
|
#
|
|
|
|
|
|
# Bevisst, kildebelagt avvik fra WHS: "Expected Score" for uspilte hull
|
|
|
|
|
|
# (Rule 3.2b) er eksplisitt beskrevet som automatisk beregnet av sertifisert
|
|
|
|
|
|
# WHS-programvare UTEN publisert formel. Etter eksplisitt instruks fra
|
|
|
|
|
|
# brukeren brukes WHS sin egen, presist DEFINERTE "Net Par"-term i stedet
|
|
|
|
|
|
# (Rule 3.2b/2 — normalt reservert for spesielle godkjente tilfeller, her
|
|
|
|
|
|
# vedtatt som TeeCups generelle policy). Konsekvens: en 9-hulls-runde
|
|
|
|
|
|
# behandles som en 18-hulls-runde med 9 Net-Par-fylte hull, gjennom SAMME
|
|
|
|
|
|
# formel som en ufullstendig 18-hulls-runde — Rule 5.1b sin egen separate
|
|
|
|
|
|
# 9-hulls-differensial-formel er derfor IKKE implementert, bevisst.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Playing Conditions Calculation (Rule 5.6) og Exceptional Score-reduksjon
|
|
|
|
|
|
# (Rule 5.9) er begge forstått, men bevisst UTENFOR omfang i v1 (se ADR-033)
|
|
|
|
|
|
# — ikke implementert her.
|
|
|
|
|
|
|
|
|
|
|
|
def net_par(par: int, strokes_received: int) -> int:
|
|
|
|
|
|
"""Net Par (Rule 3.2b/2) — par + mottatte handicapslag på hullet.
|
|
|
|
|
|
|
|
|
|
|
|
TeeCups stand-in for et uspilt hull der WHS sin egen "Expected Score"
|
|
|
|
|
|
ikke har en publisert formel (se moduldoc over). Tilsvarer 2
|
|
|
|
|
|
Stableford-poeng.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return par + strokes_received
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def max_hole_score_for_handicap(
|
|
|
|
|
|
par: int,
|
|
|
|
|
|
strokes_received: int,
|
|
|
|
|
|
*,
|
|
|
|
|
|
index_established: bool = True,
|
2026-08-10 18:34:31 +02:00
|
|
|
|
course_handicap: int | None = None,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Maks hull-score for HCP-formål (Rule 3.1).
|
|
|
|
|
|
|
|
|
|
|
|
Med etablert indeks: Net Double Bogey = par + 2 + mottatte handicapslag
|
|
|
|
|
|
(Rule 3.1b). Før en indeks er etablert i det hele tatt (spillerens aller
|
|
|
|
|
|
første score(r)): par + 5 (Rule 3.1a) — enklere cap fordi ingen
|
|
|
|
|
|
handicapslag ennå er kjent å fordele.
|
2026-08-10 18:34:31 +02:00
|
|
|
|
|
|
|
|
|
|
Unntak (Rule 3.1b, verifisert direkte mot WHS Rules of Handicapping
|
|
|
|
|
|
2024, side 37 -- ikke bare koden sin egen gjenfortelling av regelen):
|
|
|
|
|
|
"Where a Course Handicap is calculated at more than 54 and a player
|
|
|
|
|
|
receives 4 or more strokes on a hole, the maximum hole score is par + 5
|
|
|
|
|
|
for handicap purposes." Overstyrer den vanlige par+2+slag-cappen for
|
|
|
|
|
|
akkurat denne kombinasjonen (høyt banehandicap + mange slag på ett
|
|
|
|
|
|
hull) -- uten `course_handicap` sendt inn anvendes IKKE unntaket
|
|
|
|
|
|
(bakoverkompatibelt, samme oppførsel som før for alle kall som ikke
|
|
|
|
|
|
oppgir det).
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
"""
|
|
|
|
|
|
if not index_established:
|
|
|
|
|
|
return par + 5
|
2026-08-10 18:34:31 +02:00
|
|
|
|
if course_handicap is not None and course_handicap > 54 and strokes_received >= 4:
|
|
|
|
|
|
return par + 5
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
return par + 2 + strokes_received
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def adjusted_gross_score(
|
|
|
|
|
|
hole_scores: Sequence[int | None],
|
|
|
|
|
|
pars: Sequence[int],
|
|
|
|
|
|
strokes_received: Sequence[int],
|
|
|
|
|
|
*,
|
|
|
|
|
|
index_established: bool = True,
|
2026-08-10 18:34:31 +02:00
|
|
|
|
course_handicap: int | None = None,
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
) -> int:
|
|
|
|
|
|
"""18-hulls Adjusted Gross Score (Rule 3), grunnlaget for Score Differential.
|
|
|
|
|
|
|
|
|
|
|
|
- `hole_scores[i]` = spilt bruttoscore på hull i, eller `None` for et
|
|
|
|
|
|
uspilt hull (fylles med Net Par, se moduldoc).
|
2026-08-10 18:34:31 +02:00
|
|
|
|
- Spilte hull capped til `max_hole_score_for_handicap` -- `course_handicap`
|
|
|
|
|
|
videreført dit uendret, for Rule 3.1b sitt >54/4+-slag-unntak (se der).
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
- Forventer nøyaktig 18 hull i alle tre lister (bruk `None` for uspilte,
|
|
|
|
|
|
ikke kortere lister) — en 9-hulls-runde sendes inn som 18 elementer der
|
|
|
|
|
|
9 av dem er `None`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(pars)
|
|
|
|
|
|
if n != 18:
|
|
|
|
|
|
raise ValueError(f"adjusted_gross_score forventer 18 hull, fikk {n}")
|
|
|
|
|
|
if len(hole_scores) != n or len(strokes_received) != n:
|
|
|
|
|
|
raise ValueError("hole_scores og strokes_received må ha samme lengde som pars (18)")
|
|
|
|
|
|
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for score, par, strokes in zip(hole_scores, pars, strokes_received):
|
|
|
|
|
|
if score is None:
|
|
|
|
|
|
total += net_par(par, strokes)
|
|
|
|
|
|
else:
|
2026-08-10 18:34:31 +02:00
|
|
|
|
cap = max_hole_score_for_handicap(
|
|
|
|
|
|
par, strokes, index_established=index_established, course_handicap=course_handicap
|
|
|
|
|
|
)
|
Første byggesteg ferdig: HCP-indeks-motoren.
Bygget og testet i handicap_engine.py — ren Python, ingen database/API rørt ennå (samme "test i isolasjon først"-prinsipp som resten av motoren). 9 nye funksjoner dekker hele kjeden fra rå hullscore til en ferdig, cap-justert HCP-indeks: Net Par/Net Double Bogey, Adjusted Gross Score, Score Differential, Handicap Index (beste-8-av-20 med opptrappingstabell for nye spillere), Low Handicap Index, soft/hard cap, og den avvikende 9-hulls Course Handicap-formelen.
41/41 tester bestått, flere verifisert direkte mot regelbokens egne tallregneeksempler (ikke bare intern konsistens) — blant annet begge Rule 5.2a-eksemplene (13,2 / 34,1 / 37,4), alle tre Rule 5.1c-avrundingseksemplene, og soft/hard cap-oppførselen fra Diagram 5.8. For Diagram 3.1b (Net Double Bogey-eksempelet) var jeg åpen om en reell begrensning: front-9 stemte eksakt mot bildet, men jeg kunne ikke garantere hvert enkelt siffer i back-9-scorerekken fra bilde-oppløsningen — testen bruker derfor kun det ene tydelig annoterte, kildebelagte tallet (hull 17: brutto 9, capped til 7) og egenkomponerte tall for resten, dokumentert i kommentaren.
Dokumentasjonen (ADR-033, CLAUDE.md, FEATURE_BACKLOG.md) er oppdatert til å reflektere at bygging er påbegynt.
2026-07-22 10:28:01 +02:00
|
|
|
|
total += min(score, cap)
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def score_differential(
|
|
|
|
|
|
adjusted_gross_score_value: float,
|
|
|
|
|
|
course_rating: float,
|
|
|
|
|
|
slope_rating: float,
|
|
|
|
|
|
pcc_adjustment: float = 0.0,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""Score Differential for en (18-hulls-ekvivalent) runde (Rule 5.1a).
|
|
|
|
|
|
|
|
|
|
|
|
(113 / Slope Rating) x (Adjusted Gross Score - Course Rating - PCC).
|
|
|
|
|
|
`pcc_adjustment` er 0,0 som default siden PCC ikke er implementert i v1
|
|
|
|
|
|
(se moduldoc) — kalleren kan sende inn en verdi hvis/når PCC bygges
|
|
|
|
|
|
senere uten at denne funksjonen må endres.
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = (113.0 / slope_rating) * (adjusted_gross_score_value - course_rating - pcc_adjustment)
|
|
|
|
|
|
return round_half_up_decimal(raw, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Rule 5.2a — antall Score Differentials som brukes og justering, for en
|
|
|
|
|
|
# scoring-record med FÆRRE enn 20 differensialer. Nøkkel = antall
|
|
|
|
|
|
# differensialer i historikken.
|
|
|
|
|
|
_INDEX_TABLE_UNDER_20: dict[int, tuple[int, float]] = {
|
|
|
|
|
|
3: (1, -2.0),
|
|
|
|
|
|
4: (1, -1.0),
|
|
|
|
|
|
5: (1, 0.0),
|
|
|
|
|
|
6: (2, -1.0),
|
|
|
|
|
|
7: (2, 0.0),
|
|
|
|
|
|
8: (2, 0.0),
|
|
|
|
|
|
9: (3, 0.0),
|
|
|
|
|
|
10: (3, 0.0),
|
|
|
|
|
|
11: (3, 0.0),
|
|
|
|
|
|
12: (4, 0.0),
|
|
|
|
|
|
13: (4, 0.0),
|
|
|
|
|
|
14: (4, 0.0),
|
|
|
|
|
|
15: (5, 0.0),
|
|
|
|
|
|
16: (5, 0.0),
|
|
|
|
|
|
17: (6, 0.0),
|
|
|
|
|
|
18: (6, 0.0),
|
|
|
|
|
|
19: (7, 0.0),
|
|
|
|
|
|
}
|
|
|
|
|
|
_INDEX_TABLE_20_OR_MORE: tuple[int, float] = (8, 0.0) # Rule 5.2b
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handicap_index_from_differentials(differentials: Sequence[float]) -> float | None:
|
|
|
|
|
|
"""Handicap Index fra en scoring-record sine Score Differentials (Rule 5.2).
|
|
|
|
|
|
|
|
|
|
|
|
`differentials` skal være de N NYESTE differensialene (Rule 5.5 —
|
|
|
|
|
|
ageing/lapsing er kallerens ansvar å trimme til før denne kalles; denne
|
|
|
|
|
|
funksjonen bruker aldri mer enn de 20 siste selv om flere sendes inn).
|
|
|
|
|
|
|
|
|
|
|
|
Returnerer `None` hvis færre enn 3 — ingen indeks kan etableres ennå
|
|
|
|
|
|
(implisitt nedre grense i Rule 5.2a sin tabell).
|
|
|
|
|
|
|
|
|
|
|
|
Bygger IKKE inn soft/hard cap (Rule 5.8) — det krever Low Handicap
|
|
|
|
|
|
Index-historikk (se `low_handicap_index`) og gjøres separat med
|
|
|
|
|
|
`apply_index_caps`, av en kaller som har tilgang til den historikken.
|
|
|
|
|
|
"""
|
|
|
|
|
|
trimmed = list(differentials)[-20:]
|
|
|
|
|
|
n = len(trimmed)
|
|
|
|
|
|
if n < 3:
|
|
|
|
|
|
return None
|
|
|
|
|
|
count, adjustment = _INDEX_TABLE_UNDER_20.get(n, _INDEX_TABLE_20_OR_MORE)
|
|
|
|
|
|
lowest = sorted(trimmed)[:count]
|
|
|
|
|
|
avg = sum(lowest) / count
|
|
|
|
|
|
return round_half_up_decimal(avg + adjustment, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def low_handicap_index(index_history: Sequence[tuple[date, float]], as_of: date) -> float | None:
|
|
|
|
|
|
"""Low Handicap Index (Rule 5.7) — laveste indeks i de 365 dagene FØR og
|
|
|
|
|
|
MED `as_of` (typisk datoen siste runde i scoring-record ble spilt).
|
|
|
|
|
|
|
|
|
|
|
|
`index_history` er (dato, indeks)-par for hver historisk indeks-verdi —
|
|
|
|
|
|
en kaller-eid historikk (f.eks. en database-tabell), ikke noe denne
|
|
|
|
|
|
rene motoren selv holder styr på. Returnerer `None` hvis historikken er
|
|
|
|
|
|
tom i vinduet (f.eks. en helt fersk spiller).
|
|
|
|
|
|
"""
|
|
|
|
|
|
cutoff = as_of - timedelta(days=365)
|
|
|
|
|
|
eligible = [idx for d, idx in index_history if cutoff <= d <= as_of]
|
|
|
|
|
|
if not eligible:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return min(eligible)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_index_caps(new_index: float, low_handicap_index_value: float) -> float:
|
|
|
|
|
|
"""Soft cap / hard cap på OPPADGÅENDE bevegelse (Rule 5.8).
|
|
|
|
|
|
|
|
|
|
|
|
- Soft cap: økning over 3,0 slag over Low Handicap Index halveres (kun
|
|
|
|
|
|
den DELEN som overstiger 3,0, ikke hele økningen).
|
|
|
|
|
|
- Hard cap: total økning kan uansett aldri overstige 5,0 slag over Low
|
|
|
|
|
|
Handicap Index.
|
|
|
|
|
|
- Ingen nedre grense — indeksen kan alltid synke fritt.
|
|
|
|
|
|
"""
|
|
|
|
|
|
increase = new_index - low_handicap_index_value
|
|
|
|
|
|
if increase <= 3.0:
|
|
|
|
|
|
return round_half_up_decimal(new_index, 1)
|
|
|
|
|
|
capped_increase = min(3.0 + (increase - 3.0) * 0.5, 5.0)
|
|
|
|
|
|
return round_half_up_decimal(low_handicap_index_value + capped_increase, 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def course_handicap_9_raw(
|
|
|
|
|
|
handicap_index: float,
|
|
|
|
|
|
slope_rating_9: float,
|
|
|
|
|
|
course_rating_9: float,
|
|
|
|
|
|
par_9: int,
|
|
|
|
|
|
) -> float:
|
|
|
|
|
|
"""9-hulls Course Handicap, uavrundet (Rule 6.1b).
|
|
|
|
|
|
|
|
|
|
|
|
AVVIKER fra 18-hulls-formelen (`course_handicap_raw`): indeksen HALVERES
|
|
|
|
|
|
først (avrundet til nærmeste tidel), FØR den ganges med 9-hulls
|
|
|
|
|
|
slope/113. Gjelder frittstående 9-hulls-RUNDER (ADR-033) — IKKE
|
|
|
|
|
|
turnering-øktenes front_9/back_9-oppsett i `app/handicap.py`, som
|
|
|
|
|
|
bevisst bruker full_18-rating av en helt annen grunn (slagfordeling
|
|
|
|
|
|
internt i en turneringsmatch, se 2026-07-19-fiksen i CLAUDE.md-status).
|
|
|
|
|
|
De to må ikke forveksles eller slås sammen uten en egen vurdering.
|
|
|
|
|
|
"""
|
|
|
|
|
|
half_index = round_half_up_decimal(handicap_index / 2.0, 1)
|
|
|
|
|
|
return half_index * (slope_rating_9 / 113.0) + (course_rating_9 - par_9)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def course_handicap_9(
|
|
|
|
|
|
handicap_index: float,
|
|
|
|
|
|
slope_rating_9: float,
|
|
|
|
|
|
course_rating_9: float,
|
|
|
|
|
|
par_9: int,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Avrundet 9-hulls Course Handicap (Rule 6.1b)."""
|
|
|
|
|
|
return round_half_up(course_handicap_9_raw(handicap_index, slope_rating_9, course_rating_9, par_9))
|
|
|
|
|
|
|
|
|
|
|
|
|
Migrasjonen er kjørt, og jeg har fortsatt helt til et komplett, verifisert API-lag.
Underveis fant og fikset jeg et reelt hull: round_participant manglet kolonner for selve rating-tallene (kun tee-navnet var lagret) — løst med migrasjon 021, kjørt mot ekte database sammen med resten.
Bygget og rullet ut:
app/routers/rounds.py — opprett/liste/hent/slett runde, legg til/fjern gjestedeltakere, registrer statistikk hull for hull, og en fullfør-endepunkt som kjører hele kjeden (Adjusted Gross Score → Score Differential → tellende-vurdering).
26 scratch-sjekker grønt, pluss en egen test mot den ekte kjørende teeoff-tjenesten som bekreftet at live-oppslaget fungerer nøyaktig som tiltenkt (ingen bane-kopi skrives noe sted).
Begge migrasjoner + ny kode er nå live mot ekte teecup_db/teecup_api. teeoff.no upåvirket.
Jeg la også inn /rounds og /personal-courses i frontendens proxy-oppsett proaktivt, siden prosjektet har blitt bitt av glemte proxy-ruter før — ikke deployet ennå, tas med neste gang frontend faktisk bygges.
Notatet om slaglengde-måling og avstand-til-punkter-på-banen er lagret i CLAUDE.md og FEATURE_BACKLOG.md, med en tydelig flagget avhengighet: dette krever geografiske/GPS-data som ingen nåværende kilde (verken teeoff eller den nye banekatalogen) har.
2026-07-22 11:31:08 +02:00
|
|
|
|
def round_counts_for_handicap(played_holes_count: int, holes_planned: int) -> bool:
|
|
|
|
|
|
"""Er en runde HCP-tellende ut fra hvor mange av 18 hull som ble spilt?
|
|
|
|
|
|
|
|
|
|
|
|
Kilde: Rule 2.2. To distinkte terskler avhengig av spillerens ERKLÆRTE
|
|
|
|
|
|
INTENSJON (`holes_planned` — 9 eller 18, ADR-033 Beslutning E):
|
|
|
|
|
|
|
|
|
|
|
|
- Intensjon 18 hull (Rule 2.2a): minst 10 av 18 må være spilt (resten
|
|
|
|
|
|
fylles med Net Par, se `adjusted_gross_score`-moduldocen).
|
|
|
|
|
|
- Intensjon 9 hull (Rule 2.2b): ALLE 9 må være spilt — ingen "minst 9",
|
|
|
|
|
|
færre enn 9 gjør scoren helt ugyldig for HCP-formål.
|
|
|
|
|
|
|
|
|
|
|
|
Merk: Rule 2.2b sitt krav om at de 9 hullene må tilhøre et faktisk
|
|
|
|
|
|
RATET 9-hulls-sett (front/back) er ikke lenger relevant her — TeeCups
|
|
|
|
|
|
Net-Par-tilnærming (Beslutning G) bruker alltid banens fulle 18-hulls
|
|
|
|
|
|
rating, uansett hvilke konkrete hull som ble spilt.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if holes_planned == 9:
|
|
|
|
|
|
return played_holes_count == 9
|
|
|
|
|
|
return played_holes_count >= 10
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 07:18:01 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Allowance-strategier (konfigurasjon per format)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
#
|
|
|
|
|
|
# En strategi tar en liste av UAVRUNDEDE course handicaps for de spillerne som
|
|
|
|
|
|
# hører til én "handicap-bærende enhet" og returnerer den enhetens Playing
|
|
|
|
|
|
# Handicap (avrundet).
|
|
|
|
|
|
#
|
|
|
|
|
|
# - Singles/four-ball: hver spiller er sin egen enhet -> kall strategien én
|
|
|
|
|
|
# gang per spiller (liste med ett element).
|
|
|
|
|
|
# - Foursomes/greensomes/scramble: hele laget er én enhet -> kall strategien
|
|
|
|
|
|
# med lagets spillere.
|
|
|
|
|
|
|
|
|
|
|
|
class AllowanceStrategy(Protocol):
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
"""Returner avrundet Playing Handicap for enheten."""
|
|
|
|
|
|
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class PerPlayerPercentage:
|
|
|
|
|
|
"""Én prosent av hver enkelt spillers course handicap.
|
|
|
|
|
|
|
|
|
|
|
|
Singles match play = 100 %, four-ball match play = 90 %.
|
|
|
|
|
|
Forventer nøyaktig én spiller per kall (enheten er individet).
|
|
|
|
|
|
"""
|
|
|
|
|
|
percentage: float
|
|
|
|
|
|
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
if len(course_handicaps) != 1:
|
|
|
|
|
|
raise ValueError("PerPlayerPercentage forventer nøyaktig én spiller per enhet")
|
|
|
|
|
|
return round_half_up(self.percentage * course_handicaps[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class CombinedPercentage:
|
|
|
|
|
|
"""Prosent av lagets SAMLEDE course handicap. Foursomes = 50 %."""
|
|
|
|
|
|
percentage: float
|
|
|
|
|
|
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
return round_half_up(self.percentage * sum(course_handicaps))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class WeightedLowHigh:
|
|
|
|
|
|
"""Vektet lav/høy. Greensomes = 60 % laveste + 40 % høyeste."""
|
|
|
|
|
|
low_weight: float
|
|
|
|
|
|
high_weight: float
|
|
|
|
|
|
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
if len(course_handicaps) != 2:
|
|
|
|
|
|
raise ValueError("WeightedLowHigh forventer nøyaktig to spillere")
|
|
|
|
|
|
low, high = sorted(course_handicaps)
|
|
|
|
|
|
return round_half_up(self.low_weight * low + self.high_weight * high)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class RankedSplit:
|
|
|
|
|
|
"""Rangert splitt fra laveste til høyeste handicap.
|
|
|
|
|
|
|
|
|
|
|
|
Scramble (4 spillere) = 25/20/15/10. Scramble (2) = 35/15.
|
|
|
|
|
|
Antall vekter må matche antall spillere i laget.
|
|
|
|
|
|
"""
|
|
|
|
|
|
weights: tuple[float, ...]
|
|
|
|
|
|
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
if len(course_handicaps) != len(self.weights):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"RankedSplit forventer {len(self.weights)} spillere, fikk {len(course_handicaps)}"
|
|
|
|
|
|
)
|
|
|
|
|
|
ordered = sorted(course_handicaps) # laveste først
|
|
|
|
|
|
total = sum(w * ch for w, ch in zip(self.weights, ordered))
|
|
|
|
|
|
return round_half_up(total)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 21:37:32 +02:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class TeamAverage:
|
|
|
|
|
|
"""Enkelt gjennomsnitt av ALLE lagmedlemmers course handicap, avrundet.
|
|
|
|
|
|
|
|
|
|
|
|
Brukt for `scramble_solo` (scramble-lag mot enkeltspiller, variabel
|
|
|
|
|
|
lagstørrelse N>=2) -- bevisst UAVHENGIG av RankedSplit (scramble_2/
|
|
|
|
|
|
scramble_4 sine faste 35/15-/25/20/15/10-tabeller), som forutsetter
|
|
|
|
|
|
FAST lagstørrelse og ikke er kildebelagt for vilkårlig N. Ingen
|
|
|
|
|
|
rangering/vekting her, rent snitt -- bekreftet med bruker 2026-08-05
|
|
|
|
|
|
som en bevisst ny, enkel TeeCup-regel, ikke hentet fra HCP-kildene.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def playing_handicap(self, course_handicaps: Sequence[float]) -> int:
|
|
|
|
|
|
if len(course_handicaps) < 2:
|
|
|
|
|
|
raise ValueError("TeamAverage forventer minst to spillere")
|
|
|
|
|
|
return round_half_up(sum(course_handicaps) / len(course_handicaps))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 07:18:01 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Format-register (STANDARDVERDIER — kan overstyres per turnering)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class Format(str, Enum):
|
|
|
|
|
|
SINGLES = "singles"
|
|
|
|
|
|
FOURBALL = "fourball"
|
|
|
|
|
|
FOURSOME = "foursome"
|
|
|
|
|
|
GREENSOME = "greensome"
|
|
|
|
|
|
SCRAMBLE_2 = "scramble_2"
|
|
|
|
|
|
SCRAMBLE_4 = "scramble_4"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Standard match-play-allowances per R&A Appendix C (2026).
|
|
|
|
|
|
# VIKTIG: Dette er defaults. En turnering kan levere sitt eget register
|
|
|
|
|
|
# (f.eks. four-ball 85 %, eller lokale satser) uten å endre motoren.
|
|
|
|
|
|
DEFAULT_MATCHPLAY_ALLOWANCES: dict[Format, AllowanceStrategy] = {
|
|
|
|
|
|
Format.SINGLES: PerPlayerPercentage(1.00),
|
|
|
|
|
|
Format.FOURBALL: PerPlayerPercentage(0.90),
|
|
|
|
|
|
Format.FOURSOME: CombinedPercentage(0.50),
|
|
|
|
|
|
Format.GREENSOME: WeightedLowHigh(0.60, 0.40),
|
|
|
|
|
|
Format.SCRAMBLE_2: RankedSplit((0.35, 0.15)),
|
|
|
|
|
|
Format.SCRAMBLE_4: RankedSplit((0.25, 0.20, 0.15, 0.10)),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Match play: relativ slagtildeling
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def match_play_strokes(playing_handicaps: Sequence[int]) -> list[int]:
|
|
|
|
|
|
"""Gjør absolutte playing handicaps om til relative slag i match play.
|
|
|
|
|
|
|
|
|
|
|
|
Laveste enhet spiller av 0; øvrige mottar differansen fra laveste.
|
|
|
|
|
|
Rekkefølgen i output samsvarer med input.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not playing_handicaps:
|
|
|
|
|
|
return []
|
|
|
|
|
|
lowest = min(playing_handicaps)
|
|
|
|
|
|
return [ph - lowest for ph in playing_handicaps]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Slagfordeling på hull (Stroke Index)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def allocate_strokes_by_index(total_strokes: int, stroke_indexes: Sequence[int]) -> list[int]:
|
|
|
|
|
|
"""Fordel `total_strokes` mottatte slag på hull etter deres Stroke Index.
|
|
|
|
|
|
|
|
|
|
|
|
- stroke_indexes: liste med SI per hull (typisk 1..18, hardeste = 1).
|
|
|
|
|
|
- Håndterer total > antall hull: da får hvert hull et grunnslag, og de
|
|
|
|
|
|
laveste SI-hullene får et ekstra (spiller med 20 slag på 18 hull får 1
|
|
|
|
|
|
slag overalt + 1 ekstra på SI 1 og 2 -> 2 slag der).
|
|
|
|
|
|
- Håndterer minus-handicap (total < 0): spilleren GIR slag tilbake,
|
|
|
|
|
|
med start på de letteste hullene (høyest SI).
|
|
|
|
|
|
|
|
|
|
|
|
Returnerer liste med slag per hull i samme rekkefølge som stroke_indexes.
|
|
|
|
|
|
"""
|
|
|
|
|
|
n = len(stroke_indexes)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
sign = 1 if total_strokes >= 0 else -1
|
|
|
|
|
|
magnitude = abs(total_strokes)
|
|
|
|
|
|
|
|
|
|
|
|
base, extra = divmod(magnitude, n)
|
|
|
|
|
|
|
|
|
|
|
|
# Ved positive slag går ekstraslag til laveste SI (hardeste hull).
|
|
|
|
|
|
# Ved negative (gir tilbake) går de til høyeste SI (letteste hull).
|
|
|
|
|
|
if sign >= 0:
|
|
|
|
|
|
gets_extra = {si for si in stroke_indexes if si <= extra}
|
|
|
|
|
|
else:
|
|
|
|
|
|
# de `extra` høyeste SI-verdiene
|
|
|
|
|
|
threshold = n - extra
|
|
|
|
|
|
gets_extra = {si for si in stroke_indexes if si > threshold}
|
|
|
|
|
|
|
|
|
|
|
|
result = []
|
|
|
|
|
|
for si in stroke_indexes:
|
|
|
|
|
|
strokes = base + (1 if si in gets_extra else 0)
|
|
|
|
|
|
result.append(sign * strokes)
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def allocate_over_played_holes(
|
|
|
|
|
|
total_strokes: int,
|
|
|
|
|
|
all_18_stroke_indexes: Sequence[int],
|
|
|
|
|
|
played_hole_numbers: Sequence[int],
|
|
|
|
|
|
) -> list[int]:
|
|
|
|
|
|
"""Slagfordeling når bare et delsett av hullene spilles (f.eks. front/back 9).
|
|
|
|
|
|
|
|
|
|
|
|
VIKTIG: Fordel ALLTID over hele 18-hulls stroke index, og ta så ut de hullene
|
|
|
|
|
|
som faktisk spilles. Å sende bare de spilte hullene inn i
|
|
|
|
|
|
`allocate_strokes_by_index` gir feil ved høye slagtall, fordi den da ville
|
|
|
|
|
|
fordelt slagene som om totalen gjaldt kun de ni hullene (grunnslag på hvert
|
|
|
|
|
|
hull). Eksempel: 12 slag, back-9 -> riktig 6 slag, naivt 10.
|
|
|
|
|
|
|
|
|
|
|
|
- all_18_stroke_indexes: SI for hull 1..18 i hullrekkefølge (indeks 0 = hull 1).
|
|
|
|
|
|
- played_hole_numbers: hullnumrene som spilles (f.eks. [10..18] for back-9).
|
|
|
|
|
|
|
|
|
|
|
|
Returnerer slag per spilt hull, i samme rekkefølge som played_hole_numbers.
|
|
|
|
|
|
"""
|
|
|
|
|
|
full = allocate_strokes_by_index(total_strokes, all_18_stroke_indexes)
|
|
|
|
|
|
return [full[hole - 1] for hole in played_hole_numbers]
|
|
|
|
|
|
|
|
|
|
|
|
|
Backend-laget (migrasjon 040 + motor + API) er ferdig bygget og grundig scratch-verifisert — 63/63 motor-tester, 52/52 API-sjekker, test_isolation.sql fortsatt 12/12. Dokumentasjonen er oppdatert (CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md).
Bevisst utenfor denne runden: frontend (ingen skjerm ennå — samme "motor → skjema → API → frontend"-rekkefølge som tidligere ADR-er), og en senere innstramming av autorisasjon (i dag bredt org-medlemskap for alt, inkl. scoring — analogt med at ADR-023s kaptein-only kom som egen, senere runde for lagturneringer).
Før jeg ruller ut mot ekte teecup_db, her er planen:
Kjør migrasjon 040_individual_tournaments.sql mot ekte teecup_db — rent additivt: to nye kolonner på tournament (format_type default 'team', scoring_method nullable) + fem nye tabeller (tournament_round, tournament_participant, tournament_round_participant, tournament_round_hole, tournament_round_score), full RLS. Ingen eksisterende rader røres.
Verifiser at kolonnene/tabellene ble opprettet riktig, og kjør test_isolation.sql mot ekte database (forventer fortsatt 12/12).
Redeploy kun teecup_api (docker compose up -d --build teecup_api) — ren backend-endring, ingen frontend-kode denne runden.
Verifiser at containeren booter rent, /health//dashboard fortsatt 200, den nye API-stien faktisk når FastAPI (f.eks. anonymt kall gir riktig 401, ikke en rå 404), og teeoff.no er upåvirket.
2026-07-30 07:12:05 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Individuelle scoringsmetoder (ADR-037): bruttoslagspill/nettoslagspill/
|
|
|
|
|
|
# Stableford for et flatt felt av spillere, ingen motpart/side involvert.
|
|
|
|
|
|
# `strokes_received` her kommer ALLTID fra allocate_strokes_by_index/
|
|
|
|
|
|
# allocate_over_played_holes over -- disse funksjonene fordeler ingen slag
|
|
|
|
|
|
# selv, de bare summerer et allerede fordelt resultat.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def stroke_play_gross_total(gross_strokes: Sequence[int]) -> int:
|
|
|
|
|
|
"""Sum av rå bruttoslag over de spilte hullene. Ingen handicap involvert."""
|
|
|
|
|
|
return sum(gross_strokes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stroke_play_net_total(gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
|
|
|
|
|
|
"""Sum av netto slag (brutto minus mottatte slag) over de spilte hullene."""
|
|
|
|
|
|
return sum(g - s for g, s in zip(gross_strokes, strokes_received))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stableford_points_for_hole(par: int, gross_strokes: int, strokes_received: int) -> int:
|
|
|
|
|
|
"""Stableford-poeng for ETT hull: 2 poeng for netto par, +/-1 poeng per
|
|
|
|
|
|
slag avvik, gulvet på 0 (netto dobbel bogey eller dårligere gir 0 poeng).
|
|
|
|
|
|
"""
|
|
|
|
|
|
net = gross_strokes - strokes_received
|
|
|
|
|
|
return max(0, par - net + 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def stableford_total(pars: Sequence[int], gross_strokes: Sequence[int], strokes_received: Sequence[int]) -> int:
|
|
|
|
|
|
"""Sum av Stableford-poeng over de spilte hullene."""
|
|
|
|
|
|
return sum(
|
|
|
|
|
|
stableford_points_for_hole(par, g, s)
|
|
|
|
|
|
for par, g, s in zip(pars, gross_strokes, strokes_received)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 21:37:32 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# To-sidet netto-slagspill-sammenligning (scramble_solo): "laveste totalsum
|
|
|
|
|
|
# vinner", INGEN hull-for-hull-tilstand -- eksplisitt SØSKEN til, ikke del
|
|
|
|
|
|
# av, match-status-seksjonen under (som er for ekte match play). Hver sides
|
|
|
|
|
|
# nettosum beregnes med de allerede eksisterende allocate_strokes_by_index/
|
|
|
|
|
|
# stroke_play_net_total over -- ingen ny funksjon trengs for selve summen.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def net_stroke_play_margin(net_total_a: int, net_total_b: int) -> int:
|
|
|
|
|
|
"""Positiv = A leder (A har LAVEST nettosum), negativ = B leder, 0 = delt."""
|
|
|
|
|
|
return net_total_b - net_total_a
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-16 07:18:01 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Match-status ("2 UP", "dormie", "3&2", "AS")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
class HoleResult(int, Enum):
|
|
|
|
|
|
SIDE_A = 1 # side A vant hullet
|
|
|
|
|
|
HALVED = 0 # delt
|
|
|
|
|
|
SIDE_B = -1 # side B vant hullet
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class MatchState:
|
|
|
|
|
|
lead: int # positiv = A leder, negativ = B leder, 0 = all square
|
|
|
|
|
|
holes_played: int
|
|
|
|
|
|
holes_remaining: int
|
|
|
|
|
|
is_closed: bool # matchen er avgjort (ledelse > gjenstående hull)
|
|
|
|
|
|
is_dormie: bool # ledelse == gjenstående hull (motstander kan ikke vinne)
|
|
|
|
|
|
|
|
|
|
|
|
def describe(self) -> str:
|
|
|
|
|
|
"""Kort tekstlig status, f.eks. '2 UP (A)', 'AS', '3&2 (A)', 'dormie 2 (B)'."""
|
|
|
|
|
|
if self.is_closed:
|
|
|
|
|
|
# Avgjort: "X&Y" der Y = hull som gjensto da matchen ble vunnet.
|
|
|
|
|
|
winner = "A" if self.lead > 0 else "B"
|
|
|
|
|
|
margin = abs(self.lead)
|
|
|
|
|
|
if self.holes_remaining == 0 and margin > 0:
|
|
|
|
|
|
# Vunnet på siste hull
|
|
|
|
|
|
return f"{margin} UP ({winner})"
|
|
|
|
|
|
return f"{margin}&{self.holes_remaining} ({winner})"
|
|
|
|
|
|
if self.lead == 0:
|
|
|
|
|
|
return "AS"
|
|
|
|
|
|
leader = "A" if self.lead > 0 else "B"
|
|
|
|
|
|
status = f"{abs(self.lead)} UP ({leader})"
|
|
|
|
|
|
if self.is_dormie:
|
|
|
|
|
|
status = f"dormie {abs(self.lead)} ({leader})"
|
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_match_state(
|
|
|
|
|
|
hole_results: Sequence[HoleResult],
|
|
|
|
|
|
total_holes: int = 18,
|
|
|
|
|
|
) -> MatchState:
|
|
|
|
|
|
"""Beregn løpende match play-status fra hull-for-hull-resultater.
|
|
|
|
|
|
|
|
|
|
|
|
hole_results: resultatene så langt (kan være færre enn total_holes).
|
|
|
|
|
|
total_holes: antall hull i matchen (18 som standard, men støtter 9 osv.).
|
|
|
|
|
|
"""
|
|
|
|
|
|
lead = sum(int(r) for r in hole_results)
|
|
|
|
|
|
holes_played = len(hole_results)
|
|
|
|
|
|
holes_remaining = total_holes - holes_played
|
|
|
|
|
|
|
|
|
|
|
|
is_closed = abs(lead) > holes_remaining
|
|
|
|
|
|
is_dormie = (not is_closed) and holes_remaining > 0 and abs(lead) == holes_remaining
|
|
|
|
|
|
|
|
|
|
|
|
return MatchState(
|
|
|
|
|
|
lead=lead,
|
|
|
|
|
|
holes_played=holes_played,
|
|
|
|
|
|
holes_remaining=holes_remaining,
|
|
|
|
|
|
is_closed=is_closed,
|
|
|
|
|
|
is_dormie=is_dormie,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Bekvemmelighets-API: fra rådata til relative slag for en match
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class Player:
|
|
|
|
|
|
name: str
|
|
|
|
|
|
handicap_index: float
|
|
|
|
|
|
slope_rating: float
|
|
|
|
|
|
course_rating: float
|
|
|
|
|
|
par: int
|
|
|
|
|
|
|
|
|
|
|
|
def course_handicap_raw(self) -> float:
|
|
|
|
|
|
return course_handicap_raw(
|
|
|
|
|
|
self.handicap_index, self.slope_rating, self.course_rating, self.par
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def unit_playing_handicap(players: Sequence[Player], strategy: AllowanceStrategy) -> int:
|
|
|
|
|
|
"""Playing Handicap for én enhet (spiller eller lag) gitt en strategi."""
|
|
|
|
|
|
return strategy.playing_handicap([p.course_handicap_raw() for p in players])
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Skins (ADR-039 Beslutning D) -- ikke et WHS/R&A-regulert format, ingen
|
|
|
|
|
|
# tilsvarende motorstøtte fantes fra før (ulikt match play over).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class SkinsHoleLog:
|
|
|
|
|
|
"""Ett hulls bidrag til skins-oppgjøret -- brukt til å vise SELVE
|
|
|
|
|
|
hull-for-hull-forløpet (ikke bare sluttsummen), se compute_skins_detail."""
|
|
|
|
|
|
|
|
|
|
|
|
values: dict[str, int] # deltaker_id -> score (netto ELLER brutto, avhengig av kalleren)
|
|
|
|
|
|
pot_before: float # potten FØR dette hullets egen andel legges til
|
|
|
|
|
|
awarded: dict[str, float] # deltaker_id -> andel vunnet PÅ DETTE hullet (tom hvis carried)
|
|
|
|
|
|
carried: bool # sann kun ved uavgjort + tie_handling="carry" (potten ruller videre)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_skins_detail(
|
|
|
|
|
|
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
|
|
|
|
|
tie_handling: Literal["carry", "split"] = "carry",
|
|
|
|
|
|
) -> tuple[dict[str, float], list[SkinsHoleLog]]:
|
|
|
|
|
|
"""Samme beregning som compute_skins, men returnerer OGSÅ et hull-for-hull-
|
|
|
|
|
|
forløp (verdier/pott/vinner per hull) -- til bruk i en hull-for-hull-
|
|
|
|
|
|
visning der spilleren skal se HVORFOR potten endte som den gjorde, ikke
|
|
|
|
|
|
bare den ferdige totalen."""
|
|
|
|
|
|
winnings: dict[str, float] = {}
|
|
|
|
|
|
pot = 0.0
|
|
|
|
|
|
log: list[SkinsHoleLog] = []
|
|
|
|
|
|
for hole_scores in scores_by_hole:
|
|
|
|
|
|
if not hole_scores:
|
|
|
|
|
|
log.append(SkinsHoleLog(values={}, pot_before=pot, awarded={}, carried=False))
|
|
|
|
|
|
continue
|
|
|
|
|
|
pot_before = pot
|
|
|
|
|
|
pot += 1.0
|
|
|
|
|
|
lowest = min(score for _, score in hole_scores)
|
|
|
|
|
|
winners = [pid for pid, score in hole_scores if score == lowest]
|
|
|
|
|
|
awarded: dict[str, float] = {}
|
|
|
|
|
|
carried = False
|
|
|
|
|
|
if len(winners) == 1:
|
|
|
|
|
|
winnings[winners[0]] = winnings.get(winners[0], 0.0) + pot
|
|
|
|
|
|
awarded = {winners[0]: pot}
|
|
|
|
|
|
pot = 0.0
|
|
|
|
|
|
elif tie_handling == "split":
|
|
|
|
|
|
share = pot / len(winners)
|
|
|
|
|
|
for pid in winners:
|
|
|
|
|
|
winnings[pid] = winnings.get(pid, 0.0) + share
|
|
|
|
|
|
awarded = {pid: share for pid in winners}
|
|
|
|
|
|
pot = 0.0
|
|
|
|
|
|
else:
|
|
|
|
|
|
carried = True
|
|
|
|
|
|
# "carry": potten står urørt og tas med videre til neste hull.
|
|
|
|
|
|
log.append(SkinsHoleLog(values=dict(hole_scores), pot_before=pot_before, awarded=awarded, carried=carried))
|
|
|
|
|
|
return winnings, log
|
|
|
|
|
|
|
|
|
|
|
|
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
|
def compute_skins(
|
|
|
|
|
|
scores_by_hole: Sequence[Sequence[tuple[str, int]]],
|
|
|
|
|
|
tie_handling: Literal["carry", "split"] = "carry",
|
|
|
|
|
|
) -> dict[str, float]:
|
|
|
|
|
|
"""Skins vunnet per deltaker, gitt scorer hull for hull.
|
|
|
|
|
|
|
|
|
|
|
|
`scores_by_hole[i]` er (deltaker_id, score)-par for hull i+1 -- KUN for
|
|
|
|
|
|
deltakere som faktisk har registrert en score på det hullet. Scoren skal
|
|
|
|
|
|
allerede være avgjort NETTO eller BRUTTO av kalleren FØR denne funksjonen
|
|
|
|
|
|
kalles (`round.skins_scoring`) -- motoren selv vet ikke, og bryr seg ikke
|
|
|
|
|
|
om, hvilket.
|
|
|
|
|
|
|
|
|
|
|
|
- "carry": et uavgjort hull sitt "skinn" (og alt tidligere oppsamlet)
|
|
|
|
|
|
ruller videre til neste hull -- førstemann til en ENTYDIG laveste
|
|
|
|
|
|
score der tar HELE potten.
|
|
|
|
|
|
- "split": et uavgjort hull sitt skinn (og alt oppsamlet) deles LIKT
|
|
|
|
|
|
mellom de tied spillerne i stedet for å rulle videre -- kan gi
|
|
|
|
|
|
brøkdeler, derav float i returtypen.
|
|
|
|
|
|
|
|
|
|
|
|
Et hull uten noen registrerte scorer hopper stille over (verken potten
|
|
|
|
|
|
eller noen vinnere endres) -- ikke en feiltilstand.
|
|
|
|
|
|
"""
|
Ferdig og live. Oppsummert:
Offline-køen fra ADR-028 (turnering-scorekortet) er nå portert til frittstående runder — begge scoringsveivisere (vanlig og delt-ball) fungerer offline.
Portingen ble faktisk enklere enn originalen: siden PATCH-endepunktene for frittstående runder erstatter hele hull-raden per kall, kunne en køet skriving speiles direkte inn i lokal state uten egne verdi-overlays.
Samme mønster som originalen: navigator.onLine-sjekk, nettverksfeil fanges og køes, automatisk synk når nettet kommer tilbake, manuell "Synkroniser nå"-knapp, og en "Lagret lokalt · venter på synk"-indikator i selve veiviseren.
Faktisk browserverifisert denne gangen (i motsetning til den opprinnelige ADR-028-runden, som aldri ble testet i praksis) — brukte Chrome DevTools sin ekte offline-emulering: registrerte score offline, bekreftet kø i IndexedDB, gikk online igjen, bekreftet automatisk synk og at serveren faktisk mottok riktige verdier. Gjort for begge veivisertypene.
Ingen migrasjon — rullet ut mot ekte systemer, begge containere boot-et rent, teeoff.no upåvirket.
2026-07-28 18:17:49 +02:00
|
|
|
|
winnings, _log = compute_skins_detail(scores_by_hole, tie_handling)
|
Ferdig og live. Kort oppsummert:
Bygget: hele frontend for ADR-039-spillformene — utvidet spilleform-velgeren i "Ny runde" til alle åtte formater med skins-konfig, en ny SidesPanel i "Spillere og runde" (opprett/slett sider, tildel/fjern spillere med sanntids kapasitetssperre), en ny FormatResultPanel (løpende matchstatus for match/fourball/foursome/greensome/scramble, skins-tavle for skins), setup_complete-gating av "Fullfør runde", og et eget delt-ball-scorekort+veiviser for foursome/greensome/scramble.
Testet reelt i nettleser (Chrome DevTools mot en isolert scratch-backend, ikke bare typesjekk) — spilte gjennom en komplett match-, skins- og foursome-runde fra bunnen av. Fant og fikset to reelle stale-state-buger underveis (setup-melding og matchstatus ble stående utdatert etter side-tildeling til de ikke lenger refetchet runden). Skins- og foursome-handicap-matematikken kryssjekket for hånd og stemte eksakt.
Rullet ut mot ekte systemer — ingen migrasjon, kun teecup_frontend bygget på nytt, begge containere boot-et rent, teeoff.no upåvirket. ADR-039 er dermed helt ferdig, backend og frontend.
2026-07-28 13:00:50 +02:00
|
|
|
|
return winnings
|
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").
2026-07-30 14:54:11 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 10:38:54 +02:00
|
|
|
|
def flag_lap_and_hole(holes_completed: int, play_order: Sequence[int]) -> tuple[int, int]:
|
|
|
|
|
|
"""Runde 2+-utvidelse (2026-08-14, GPS-flaggplanting) -- "fortsettelse på
|
|
|
|
|
|
hull 19+" er nå støttet: går budsjettet IKKE tomt innen 18 hull, spilles
|
|
|
|
|
|
play_order på nytt fra begynnelsen med gjenværende slag (lap 2, lap 3,
|
|
|
|
|
|
...). `flag_result()` selv trenger INGEN endring for dette -- den er
|
|
|
|
|
|
allerede lap-agnostisk, kalleren limer ganske enkelt sammen lap 1 sin
|
|
|
|
|
|
FULLE 18-lange prefiks med lap 2 sin (kun hvis lap 1 faktisk ble
|
|
|
|
|
|
fullført) osv. før den kalles, og `holes_completed` blir da naturlig
|
|
|
|
|
|
et tall som kan overstige `len(play_order)`.
|
|
|
|
|
|
|
|
|
|
|
|
Denne funksjonen oversetter et slikt flatt `holes_completed`-tall
|
|
|
|
|
|
(fra `flag_result()`) til (lap, hullnummer) -- lap 1 = `play_order`
|
|
|
|
|
|
som den er, lap 2 = samme rekkefølge på nytt, osv. Brukes til å
|
|
|
|
|
|
beregne hvilket hull en spiller reelt er "i gang med" (der flagget
|
|
|
|
|
|
skal plantes), og til server-side å validere at et innsendt
|
|
|
|
|
|
flagg-plant-forsøk faktisk stemmer med registrert scoredata."""
|
|
|
|
|
|
n = len(play_order)
|
|
|
|
|
|
lap = holes_completed // n + 1
|
|
|
|
|
|
hole_index_in_lap = holes_completed % n
|
|
|
|
|
|
return lap, play_order[hole_index_in_lap]
|
|
|
|
|
|
|
|
|
|
|
|
|
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").
2026-07-30 14:54:11 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# 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)
|
Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.
Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Order of Merit (sesong-sammenlagt rangering på tvers av flere individuelle
|
|
|
|
|
|
# turneringer) -- ADR-043 [OOM]. To uavhengige akser: HVA telles per lenket
|
|
|
|
|
|
# turnering (result_type -- håndtert av kalleren, som utleder tallet fra en
|
|
|
|
|
|
# turnerings egen leaderboard/plassering) og HVORDAN det summeres over flere
|
|
|
|
|
|
# turneringer/spillere (aggregation_mode -- håndtert her).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def order_of_merit_points_for_position(position_label: str, points_table: Sequence[int]) -> int:
|
|
|
|
|
|
"""Poeng for en gitt plassering i en poeng-etter-plassering-OOM.
|
|
|
|
|
|
|
|
|
|
|
|
`position_label` er samme form som individual_leaderboard allerede
|
|
|
|
|
|
produserer ("1", "T2", "T3" osv.) -- en ledende "T" strippes før
|
|
|
|
|
|
oppslag. `points_table[0]` er poeng for 1. plass, `points_table[1]`
|
|
|
|
|
|
for 2. plass, osv. (1-indeksert plassering, 0-indeksert liste).
|
|
|
|
|
|
|
|
|
|
|
|
Uavgjorte deler SAMME poengverdi ved sin felles (delte) plassering --
|
|
|
|
|
|
IKKE gjennomsnittet av de "oppbrukte" plassene. Enkleste, mest
|
|
|
|
|
|
forutsigbare regel, og matcher hvordan GolfBox sin egen poeng-etter-
|
|
|
|
|
|
plassering-modell oppfører seg.
|
|
|
|
|
|
|
|
|
|
|
|
Plassering utenfor tabellen (feltet er større enn poeng-tabellen
|
|
|
|
|
|
dekker) gir 0 poeng, ikke en feil -- en kortere poeng-tabell enn
|
|
|
|
|
|
feltstørrelsen er en gyldig, vanlig konfigurasjon (kun topp-N får
|
|
|
|
|
|
poeng)."""
|
|
|
|
|
|
position = int(position_label.lstrip("T"))
|
|
|
|
|
|
index = position - 1
|
|
|
|
|
|
if index < 0 or index >= len(points_table):
|
|
|
|
|
|
return 0
|
|
|
|
|
|
return points_table[index]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def order_of_merit_aggregate(
|
|
|
|
|
|
values: Sequence[float], mode: Literal["sum", "average"], best_n: int | None
|
|
|
|
|
|
) -> float | None:
|
|
|
|
|
|
"""Slår sammen en spillers (eller et lags) tellende resultater til ett
|
|
|
|
|
|
OOM-sammenlagt tall.
|
|
|
|
|
|
|
|
|
|
|
|
`None` hvis `values` er tom (ingen tellende resultater ennå -- kalleren
|
|
|
|
|
|
avgjør om det betyr "sist" eller "ikke rangert i det hele tatt").
|
|
|
|
|
|
`best_n=None` betyr "tell alle" -- ellers beholdes kun de `best_n`
|
|
|
|
|
|
STØRSTE verdiene før summering/snitt (dropp-dårligst).
|
|
|
|
|
|
|
|
|
|
|
|
"Best" er alltid STØRRE=bedre for denne funksjonens formål -- for
|
|
|
|
|
|
brutto/nettoslag (der LAVERE er bedre i golf) må kalleren sende inn
|
|
|
|
|
|
NEGERTE verdier, samme triks som allerede brukt i
|
|
|
|
|
|
individual_tournaments.py sin til-par-normaliserte rangeringsnøkkel
|
|
|
|
|
|
(2026-08-04)."""
|
|
|
|
|
|
if not values:
|
|
|
|
|
|
return None
|
|
|
|
|
|
kept = sorted(values, reverse=True)[:best_n] if best_n is not None else list(values)
|
|
|
|
|
|
if mode == "sum":
|
|
|
|
|
|
return sum(kept)
|
|
|
|
|
|
return sum(kept) / len(kept)
|