""" 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 from datetime import date, timedelta from decimal import Decimal, ROUND_FLOOR from enum import Enum from typing import Literal, Protocol, Sequence # --------------------------------------------------------------------------- # 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)) 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, course_handicap: int | None = None, ) -> 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. 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). """ if not index_established: return par + 5 if course_handicap is not None and course_handicap > 54 and strokes_received >= 4: return par + 5 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, course_handicap: int | None = None, ) -> 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). - 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). - 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: cap = max_hole_score_for_handicap( par, strokes, index_established=index_established, course_handicap=course_handicap ) 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)) 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 # --------------------------------------------------------------------------- # 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) @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)) # --------------------------------------------------------------------------- # 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] # --------------------------------------------------------------------------- # 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) ) # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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]) # --------------------------------------------------------------------------- # Skins (ADR-039 Beslutning D) -- ikke et WHS/R&A-regulert format, ingen # tilsvarende motorstøtte fantes fra før (ulikt match play over). # --------------------------------------------------------------------------- @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 def compute_skins( scores_by_hole: Sequence[Sequence[tuple[str, int]]], tie_handling: Literal["carry", "split"] = "carry", ) -> dict[str, float]: """Skins vunnet per deltaker, gitt scorer hull for hull. `scores_by_hole[i]` er (deltaker_id, score)-par for hull i+1 -- KUN for deltakere som faktisk har registrert en score på det hullet. Scoren skal allerede være avgjort NETTO eller BRUTTO av kalleren FØR denne funksjonen kalles (`round.skins_scoring`) -- motoren selv vet ikke, og bryr seg ikke om, hvilket. - "carry": et uavgjort hull sitt "skinn" (og alt tidligere oppsamlet) ruller videre til neste hull -- førstemann til en ENTYDIG laveste score der tar HELE potten. - "split": et uavgjort hull sitt skinn (og alt oppsamlet) deles LIKT mellom de tied spillerne i stedet for å rulle videre -- kan gi brøkdeler, derav float i returtypen. Et hull uten noen registrerte scorer hopper stille over (verken potten eller noen vinnere endres) -- ikke en feiltilstand. """ winnings, _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) 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] # --------------------------------------------------------------------------- # 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) # --------------------------------------------------------------------------- # 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)