Spørsmålene dine avdekket en reell bug (ikke bare et åpent spørsmål): siden ingenting eksplisitt lukker en match, kan noen fortsette å legge inn hull etter at matchen matematisk er avgjort, og det kan endre den cachede marginen ved neste omregning. Fanget i FEATURE_BACKLOG.md. Basert på svarene dine: Match-lås bygges snart — neste lille runde, tetter spøkelses-hull-buggen. Walkover/konsesjon venter til brukerroller (kaptein/organisator) er avgjort. Alt er dokumentert i FEATURE_BACKLOG.md under en ny seksjon, og CLAUDE.md er oppdatert.
199 lines
7.1 KiB
Python
199 lines
7.1 KiB
Python
"""
|
|
DB-bevisst bro mellom handicap_engine.py (ren, uten DB-avhengigheter) og
|
|
Postgres. Se ADR-005 (allowance er konfig), ADR-014 (fire uavhengige brytere),
|
|
ADR-007 (handicap_index_snapshot fryses i team_roster).
|
|
|
|
Kjent, bevisst begrensning (se plan): hvis en side aldri når forventet
|
|
deltakerantall, eller en deltakers tee mangler en matchende tee_rating-rad,
|
|
beregnes handicap for den siden ALDRI automatisk her -- ingen bakgrunnsjobb
|
|
prøver på nytt. Dette løses senere (sannsynligvis en organisator-overstyring).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from asyncpg import Connection
|
|
|
|
from handicap_engine import (
|
|
AllowanceStrategy,
|
|
CombinedPercentage,
|
|
DEFAULT_MATCHPLAY_ALLOWANCES,
|
|
Format,
|
|
PerPlayerPercentage,
|
|
RankedSplit,
|
|
WeightedLowHigh,
|
|
course_handicap_raw,
|
|
match_play_strokes,
|
|
round_half_up,
|
|
)
|
|
|
|
# Formater der ENHETEN som deler én Playing Handicap er SIDEN (foursome-paret,
|
|
# greensome-paret, hele scramble-laget) -- ikke spilleren. Singles/fourball er
|
|
# ikke med her: der er enheten spilleren, og beregnes individuelt.
|
|
_SIDE_IS_UNIT = {"foursome", "greensome", "scramble_2", "scramble_4"}
|
|
|
|
FORMAT_UNIT_SIZE: dict[str, int] = {
|
|
"singles": 1,
|
|
"fourball": 1,
|
|
"foursome": 2,
|
|
"greensome": 2,
|
|
"scramble_2": 2,
|
|
"scramble_4": 4,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AllowanceConfig:
|
|
use_handicap: bool
|
|
use_course_handicap: bool
|
|
use_matchplay_handicap: bool
|
|
strategy: AllowanceStrategy
|
|
|
|
|
|
def _strategy_from_json(format_: str, spec: dict | None) -> AllowanceStrategy:
|
|
if spec is None:
|
|
return DEFAULT_MATCHPLAY_ALLOWANCES[Format(format_)]
|
|
kind = spec["type"]
|
|
if kind == "per_player":
|
|
return PerPlayerPercentage(spec["percentage"])
|
|
if kind == "combined":
|
|
return CombinedPercentage(spec["percentage"])
|
|
if kind == "weighted_low_high":
|
|
return WeightedLowHigh(spec["low_weight"], spec["high_weight"])
|
|
if kind == "ranked_split":
|
|
return RankedSplit(tuple(spec["weights"]))
|
|
raise ValueError(f"Ukjent allowance-strategitype: {kind!r}")
|
|
|
|
|
|
def parse_allowance_config(format_: str, allowance_override: dict | None) -> AllowanceConfig:
|
|
"""ADR-014: fire uavhengige brytere. Manglende nøkler = på/standard."""
|
|
override = allowance_override or {}
|
|
return AllowanceConfig(
|
|
use_handicap=override.get("use_handicap", True),
|
|
use_course_handicap=override.get("use_course_handicap", True),
|
|
use_matchplay_handicap=override.get("use_matchplay_handicap", True),
|
|
strategy=_strategy_from_json(format_, override.get("strategy")),
|
|
)
|
|
|
|
|
|
async def compute_and_store_side_handicaps(
|
|
conn: Connection,
|
|
match_id: str,
|
|
team_side: str,
|
|
format_: str,
|
|
hole_config: str,
|
|
config: AllowanceConfig,
|
|
) -> None:
|
|
"""Beregn og lagre course_handicap/playing_handicap for én side i en match.
|
|
|
|
Kalles fra matches.py sin add_participant RETT ETTER hver innsetting.
|
|
Avgjør selv om den er klar til å beregne:
|
|
- singles/fourball (enhet = spiller): beregner uansett, for hver
|
|
deltaker som finnes (idempotent -- trygt å kalle flere ganger).
|
|
- foursome/greensome/scramble (enhet = side): beregner KUN når siden har
|
|
nøyaktig FORMAT_UNIT_SIZE[format] deltakere OG alle har en matchende
|
|
tee_rating -- ellers returnerer den uten å skrive noe (se
|
|
modul-docstring).
|
|
"""
|
|
participants = await conn.fetch(
|
|
"""
|
|
SELECT mp.id::text AS id,
|
|
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
|
|
tee_rating.course_rating::float AS course_rating,
|
|
tee_rating.slope_rating::float AS slope_rating,
|
|
tee_rating.par AS par
|
|
FROM match_participant mp
|
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
|
JOIN tee_rating ON tee_rating.tee_id = mp.tee_id AND tee_rating.scope = $3::rating_scope
|
|
WHERE mp.match_id = $1 AND mp.team_side = $2::team_side
|
|
""",
|
|
match_id,
|
|
team_side,
|
|
hole_config,
|
|
)
|
|
|
|
if format_ in _SIDE_IS_UNIT:
|
|
expected = FORMAT_UNIT_SIZE[format_]
|
|
total_on_side = await conn.fetchval(
|
|
"SELECT count(*) FROM match_participant WHERE match_id = $1 AND team_side = $2::team_side",
|
|
match_id,
|
|
team_side,
|
|
)
|
|
if total_on_side != expected or len(participants) != expected:
|
|
return # ikke komplett ennå, eller mangler tee_rating for noen
|
|
|
|
if not participants:
|
|
return
|
|
|
|
if not config.use_handicap:
|
|
for p in participants:
|
|
await conn.execute(
|
|
"UPDATE match_participant SET course_handicap = 0, playing_handicap = 0 WHERE id = $1",
|
|
p["id"],
|
|
)
|
|
return
|
|
|
|
course_handicaps = [
|
|
course_handicap_raw(p["handicap_index_snapshot"], p["slope_rating"], p["course_rating"], p["par"])
|
|
if config.use_course_handicap
|
|
else p["handicap_index_snapshot"]
|
|
for p in participants
|
|
]
|
|
|
|
if format_ in _SIDE_IS_UNIT:
|
|
playing = config.strategy.playing_handicap(course_handicaps)
|
|
for p, ch in zip(participants, course_handicaps):
|
|
await conn.execute(
|
|
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
|
|
round_half_up(ch),
|
|
playing,
|
|
p["id"],
|
|
)
|
|
else:
|
|
for p, ch in zip(participants, course_handicaps):
|
|
playing = config.strategy.playing_handicap([ch])
|
|
await conn.execute(
|
|
"UPDATE match_participant SET course_handicap = $1, playing_handicap = $2 WHERE id = $3",
|
|
round_half_up(ch),
|
|
playing,
|
|
p["id"],
|
|
)
|
|
|
|
|
|
async def relative_strokes_for_match(
|
|
conn: Connection, match_id: str, format_: str, config: AllowanceConfig
|
|
) -> dict[str, int]:
|
|
"""Relative slag per scoringsenhet i matchen.
|
|
|
|
Nøkkel = match_participant_id for singles/fourball (fourball: ALLE fire
|
|
spillerne i matchen samlet i én liste før match_play_strokes -- jf. R&A-
|
|
regelen om at laveste av alle fire spiller av scratch, ikke side-vis).
|
|
Nøkkel = team_side ('a'/'b') for foursome/greensome/scramble.
|
|
|
|
Returnerer {} hvis handicap ikke er beregnet for én eller flere enheter
|
|
ennå (se compute_and_store_side_handicaps) -- kalleren tolker det som
|
|
"kan ikke avgjøre noen hull ennå".
|
|
"""
|
|
if format_ in _SIDE_IS_UNIT:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT DISTINCT ON (team_side) team_side::text AS unit, playing_handicap
|
|
FROM match_participant
|
|
WHERE match_id = $1
|
|
ORDER BY team_side
|
|
""",
|
|
match_id,
|
|
)
|
|
else:
|
|
rows = await conn.fetch(
|
|
"SELECT id::text AS unit, playing_handicap FROM match_participant WHERE match_id = $1",
|
|
match_id,
|
|
)
|
|
|
|
if not rows or any(r["playing_handicap"] is None for r in rows):
|
|
return {}
|
|
|
|
absolutes = [r["playing_handicap"] for r in rows]
|
|
relative = match_play_strokes(absolutes) if config.use_matchplay_handicap else absolutes
|
|
return {r["unit"]: v for r, v in zip(rows, relative)}
|