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.
667 lines
26 KiB
Python
667 lines
26 KiB
Python
"""
|
||
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]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|