teecup/handicap_engine.py
Erol Haagenrud 47bebc46a2 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

701 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
) -> 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.
"""
if not index_established:
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,
) -> 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`.
- 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)
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)
# ---------------------------------------------------------------------------
# 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)
)
# ---------------------------------------------------------------------------
# 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