From 5873db18d10ecf659039ea3ece15901ba592e687 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 16 Jul 2026 07:18:01 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8rste=20commit=20for=20teecup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 19 + 001_initial_schema.sql | 410 ++++++++++++++++++++ ARCHITECTURE_DECISIONS.md | 202 ++++++++++ FEATURE_BACKLOG.md | 111 ++++++ __pycache__/handicap_engine.cpython-312.pyc | Bin 0 -> 14504 bytes handicap_engine.py | 338 ++++++++++++++++ test_handicap_engine.py | 310 +++++++++++++++ 7 files changed, 1390 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 001_initial_schema.sql create mode 100644 ARCHITECTURE_DECISIONS.md create mode 100644 FEATURE_BACKLOG.md create mode 100644 __pycache__/handicap_engine.cpython-312.pyc create mode 100644 handicap_engine.py create mode 100644 test_handicap_engine.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3614851 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(python3 -m venv /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/venv)", + "Bash(apt list *)", + "Bash(apt-cache search *)", + "Bash(sudo -n apt-get install -y python3-pytest)", + "Bash(apt-cache policy *)", + "Bash(python3 test_handicap_engine.py)", + "Bash(find /opt/teeoff -maxdepth 2 -iname \"docker-compose*\" -o -maxdepth 2 -iname \"compose*.yml\" -o -maxdepth 2 -iname \"compose*.yaml\" 2>/dev/null)", + "Read(//opt/teeoff/**)", + "Bash(docker exec *)", + "Bash(docker cp *)", + "Bash(tee /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/schema_run.log)", + "Bash(echo \"EXIT:$?\")", + "Read(//tmp/**)" + ] + } +} diff --git a/001_initial_schema.sql b/001_initial_schema.sql new file mode 100644 index 0000000..788634e --- /dev/null +++ b/001_initial_schema.sql @@ -0,0 +1,410 @@ +-- ===================================================================== +-- TeeCup — initielt databaseskjema (migrasjon 001) +-- ===================================================================== +-- Realiserer arkitektur-beslutningene: +-- ADR-001 tenant = organisasjon -> organization_id på alle domenetabeller +-- ADR-002 bruker != medlemskap -> app_user / organization_membership +-- ADR-003 shared schema + RLS -> policy på hver domenetabell +-- ADR-005 allowance = konfig -> session.allowance_override (jsonb) +-- +-- Kjøremiljø: PostgreSQL 15+ (gen_random_uuid(), FORCE ROW LEVEL SECURITY, +-- og security_invoker-views for at RLS skal gjelde gjennom viewet). +-- +-- VIKTIG DRIFTSKRAV (ADR-003): +-- Applikasjonen MÅ koble til som en rolle UTEN superuser/BYPASSRLS, og MÅ +-- sette organisasjonskonteksten per transaksjon: +-- SET LOCAL app.current_org = ''; +-- Uten dette returnerer RLS-policyene null rader (trygg standard: se ingenting). +-- ===================================================================== + +CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS citext; -- case-insensitiv e-post + +-- --------------------------------------------------------------------- +-- Enum-typer (stabile, små mengder) +-- --------------------------------------------------------------------- +CREATE TYPE team_side AS ENUM ('a', 'b'); +CREATE TYPE hole_scope AS ENUM ('full_18', 'front_9', 'back_9'); +CREATE TYPE tournament_status AS ENUM ('draft', 'active', 'completed', 'archived'); +CREATE TYPE course_source AS ENUM ('official', 'custom'); +CREATE TYPE rating_scope AS ENUM ('full_18', 'front_9', 'back_9'); + +-- Format holdes som tekst med CHECK (ikke enum), slik at nye varianter kan +-- legges til uten ALTER TYPE. Verdiene speiler Format-enumen i handicap_engine.py. +-- Selve prosenttildelingen (allowance) er konfig, ikke låst her (ADR-005). + + +-- ===================================================================== +-- 1. Identitet og tenancy (ADR-002) +-- ===================================================================== + +-- Global identitet. IKKE organisasjonsavgrenset. Ingen RLS på org-nøkkel her. +CREATE TABLE app_user ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext, -- ev. NULL for magic-link-only e.l. + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE organization ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + slug text UNIQUE, -- f.eks. subdomene/URL-vennlig + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Bindeleddet bruker <-> organisasjon. Én bruker kan tilhøre flere org (ADR-002). +CREATE TABLE organization_membership ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + role text NOT NULL DEFAULT 'member' + CHECK (role IN ('owner', 'admin', 'member')), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, user_id) +); + + +-- ===================================================================== +-- 2. Personer (spiller-pool på organisasjonsnivå) +-- ===================================================================== +-- En spiller er en person i organisasjonens register. Mange golfere i en +-- vennegjeng/klubb har ikke egen konto, derfor er player adskilt fra app_user; +-- user_id kobler valgfritt en spiller til en innlogget bruker. +CREATE TABLE player ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + user_id uuid REFERENCES app_user(id) ON DELETE SET NULL, + display_name text NOT NULL, + -- Gjeldende/standard handicap-indeks. Snapshottes per turnering i team_roster. + handicap_index numeric(4,1), + gender text CHECK (gender IN ('m', 'f', 'x')), -- for kjønnsspesifikke tee-ratinger + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, id) -- for sammensatte FK-er +); + + +-- ===================================================================== +-- 3. Baner (ADR-004: offisielle hentes fra teeoff via API, custom lages lokalt) +-- ===================================================================== +CREATE TABLE course ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + name text NOT NULL, + source course_source NOT NULL DEFAULT 'custom', + -- Referanse til offisiell bane i teeoff_db (kun når source = 'official'). + -- Ren referanse — ingen fysisk kryss-database-FK (ADR-004). + external_course_ref text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, id), + CHECK (source <> 'official' OR external_course_ref IS NOT NULL) +); + +-- Hull med par og stroke index (SI). SI er normalt konstant per bane. +CREATE TABLE hole ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + course_id uuid NOT NULL, + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + par smallint NOT NULL CHECK (par BETWEEN 3 AND 6), + stroke_index smallint NOT NULL CHECK (stroke_index BETWEEN 1 AND 18), + FOREIGN KEY (organization_id, course_id) + REFERENCES course(organization_id, id) ON DELETE CASCADE, + UNIQUE (course_id, hole_number), + UNIQUE (course_id, stroke_index) -- SI må være unik per bane +); + +-- Tee (utslagssted). Rating/slope varierer per tee. +CREATE TABLE tee ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + course_id uuid NOT NULL, + name text NOT NULL, -- f.eks. "Gul", "Rød", "Hvit" + gender text CHECK (gender IN ('m', 'f', 'x')), + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, course_id) + REFERENCES course(organization_id, id) ON DELETE CASCADE, + UNIQUE (organization_id, id) +); + +-- WHS har separate ratinger for fulle 18, front 9 og back 9. Derav egen tabell +-- slik at 9-hulls økter (front/back) bruker riktig rating (viktig for 9-hulls +-- course handicap). +CREATE TABLE tee_rating ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tee_id uuid NOT NULL, + scope rating_scope NOT NULL, + course_rating numeric(4,1) NOT NULL, + slope_rating smallint NOT NULL CHECK (slope_rating BETWEEN 55 AND 155), + par smallint NOT NULL, -- par for det aktuelle omfanget + FOREIGN KEY (organization_id, tee_id) + REFERENCES tee(organization_id, id) ON DELETE CASCADE, + UNIQUE (tee_id, scope) +); + + +-- ===================================================================== +-- 4. Turneringsstruktur +-- ===================================================================== +CREATE TABLE tournament ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE, + name text NOT NULL, + status tournament_status NOT NULL DEFAULT 'draft', + start_date date, + created_by uuid REFERENCES app_user(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, id) +); + +-- Lag i en turnering. Ryder Cup = 2 lag, men modellen låser ikke antallet. +CREATE TABLE team ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_id uuid NOT NULL, + name text NOT NULL, -- f.eks. "Team Europa" + color text, -- for UI + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, tournament_id) + REFERENCES tournament(organization_id, id) ON DELETE CASCADE, + UNIQUE (organization_id, id), + UNIQUE (tournament_id, name) +); + +-- SPILLER-POOL: alle spillere som er tatt ut på et lag for turneringen. +-- Reserver er ganske enkelt rader her som ikke får en match_participant i en +-- gitt økt. handicap_index_snapshot fryser indeksen for reproduserbare resultater. +CREATE TABLE team_roster ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + team_id uuid NOT NULL, + player_id uuid NOT NULL, + handicap_index_snapshot numeric(4,1), -- fryst ved uttak + is_captain boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, team_id) + REFERENCES team(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, player_id) + REFERENCES player(organization_id, id) ON DELETE RESTRICT, + UNIQUE (organization_id, id), + UNIQUE (team_id, player_id) -- en spiller én gang per lag +); + +-- ØKT (session): ett format, ett hullomfang, én allowance-konfig. En turnering +-- er en ORDNET sekvens av økter (foursome -> fourball -> singles ...). +CREATE TABLE session ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_id uuid NOT NULL, + sequence smallint NOT NULL, -- rekkefølge i turneringen (1,2,3...) + name text, -- f.eks. "Lørdag formiddag" + format text NOT NULL CHECK (format IN + ('singles','fourball','foursome','greensome','scramble_2','scramble_4')), + hole_config hole_scope NOT NULL DEFAULT 'full_18', + course_id uuid NOT NULL, + points_per_match numeric(3,1) NOT NULL DEFAULT 1.0, + -- ADR-005: NULL => bruk motorens standard-allowance for formatet. + -- Ellers en overstyring, f.eks. {"type":"per_player","percentage":0.85}. + allowance_override jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, tournament_id) + REFERENCES tournament(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, course_id) + REFERENCES course(organization_id, id) ON DELETE RESTRICT, + UNIQUE (organization_id, id), + UNIQUE (tournament_id, sequence) +); + +-- MATCH: én kamp i en økt, side a mot side b. Poeng caches når resultatet er +-- avgjort (beregnes av handicap_engine; SQL summerer bare de cachede poengene). +CREATE TABLE match ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + session_id uuid NOT NULL, + sequence smallint NOT NULL, -- kamp-nr innen økten + team_a_id uuid NOT NULL, -- hvilket lag er side a + team_b_id uuid NOT NULL, -- hvilket lag er side b + -- Cachede resultatfelt (kilde: motoren). NULL = ikke avgjort ennå. + status_text text, -- f.eks. "3&2 (A)", "AS" + points_side_a numeric(3,1), + points_side_b numeric(3,1), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, session_id) + REFERENCES session(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, team_a_id) + REFERENCES team(organization_id, id) ON DELETE RESTRICT, + FOREIGN KEY (organization_id, team_b_id) + REFERENCES team(organization_id, id) ON DELETE RESTRICT, + UNIQUE (organization_id, id), + UNIQUE (session_id, sequence), + CHECK (team_a_id <> team_b_id) +); + +-- Hvilke spillere fra poolen som faktisk spiller matchen, og på hvilken side. +-- 1 deltaker per side i singles, 2 i foursome/fourball/greensome, N i scramble. +-- (Antallet vs. format valideres i applikasjonslaget.) +CREATE TABLE match_participant ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + match_id uuid NOT NULL, + team_side team_side NOT NULL, + team_roster_id uuid NOT NULL, -- peker inn i poolen + tee_id uuid NOT NULL, -- tee spilleren bruker (mixed tees støttes) + -- Cachede handicap-felt (kilde: motoren, ut fra indeks-snapshot + tee + allowance). + course_handicap smallint, + playing_handicap smallint, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, match_id) + REFERENCES match(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, team_roster_id) + REFERENCES team_roster(organization_id, id) ON DELETE RESTRICT, + FOREIGN KEY (organization_id, tee_id) + REFERENCES tee(organization_id, id) ON DELETE RESTRICT, + UNIQUE (organization_id, id), + UNIQUE (match_id, team_roster_id) -- en spiller kan ikke spille to plasser i samme match +); + + +-- ===================================================================== +-- 5. Score (kilde-sannhet: brutto slag per hull) +-- ===================================================================== +-- Netto beregnes av motoren (brutto - tildelte slag på hullet). Vi lagrer ikke +-- netto; det er avledet. Matchresultat caches på match-raden. +-- +-- To scoringsmoduser i samme tabell: +-- * Individuell ball (singles, fourball): match_participant_id satt. +-- * Delt ball (foursome, greensome, scramble): match_participant_id = NULL, +-- scoren tilhører HELE siden (team_side). +-- Hvilken modus som gjelder styres av øktens format (håndheves i app-laget). +CREATE TABLE hole_score ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + match_id uuid NOT NULL, + team_side team_side NOT NULL, + match_participant_id uuid, -- NULL for delt-ball-formater + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + gross_strokes smallint NOT NULL CHECK (gross_strokes BETWEEN 1 AND 20), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, match_id) + REFERENCES match(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, match_participant_id) + REFERENCES match_participant(organization_id, id) ON DELETE CASCADE +); + +-- Unik score per spiller per hull (individuell ball) ... +CREATE UNIQUE INDEX hole_score_player_unique + ON hole_score (match_participant_id, hole_number) + WHERE match_participant_id IS NOT NULL; + +-- ... og unik score per side per hull (delt ball). +CREATE UNIQUE INDEX hole_score_side_unique + ON hole_score (match_id, team_side, hole_number) + WHERE match_participant_id IS NULL; + + +-- ===================================================================== +-- 6. Indekser (RLS-predikat og FK-oppslag; Postgres indekserer ikke FK automatisk) +-- ===================================================================== +CREATE INDEX ON player (organization_id); +CREATE INDEX ON course (organization_id); +CREATE INDEX ON hole (organization_id, course_id); +CREATE INDEX ON tee (organization_id, course_id); +CREATE INDEX ON tee_rating (organization_id, tee_id); +CREATE INDEX ON tournament (organization_id); +CREATE INDEX ON team (organization_id, tournament_id); +CREATE INDEX ON team_roster (organization_id, team_id); +CREATE INDEX ON team_roster (organization_id, player_id); +CREATE INDEX ON session (organization_id, tournament_id); +CREATE INDEX ON match (organization_id, session_id); +CREATE INDEX ON match_participant (organization_id, match_id); +CREATE INDEX ON hole_score (organization_id, match_id); + + +-- ===================================================================== +-- 7. Row-Level Security (ADR-003) +-- ===================================================================== +-- Standard org-isolasjonspolicy på hver domenetabell. Konteksten settes av +-- applikasjonen via: SET LOCAL app.current_org = ''; +-- current_setting(..., true) gir NULL (ikke feil) når konteksten mangler -> +-- policyen slipper da ingen rader gjennom (trygg standard). +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'player','course','hole','tee','tee_rating', + 'tournament','team','team_roster','session', + 'match','match_participant','hole_score' + ] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t); + EXECUTE format($p$ + CREATE POLICY org_isolation ON %I + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); + $p$, t); + END LOOP; +END $$; + +-- Organisasjonsraden selv: synlig kun for den aktive konteksten. +ALTER TABLE organization ENABLE ROW LEVEL SECURITY; +ALTER TABLE organization FORCE ROW LEVEL SECURITY; +CREATE POLICY org_self ON organization + USING (id = current_setting('app.current_org', true)::uuid); + +-- MERK: app_user og organization_membership håndteres av auth-laget (en bruker +-- må kunne se sine EGNE medlemskap for å velge organisasjon). Egne policyer for +-- disse defineres sammen med autentiseringsdesignet, ikke her. + + +-- ===================================================================== +-- 8. Leaderboard-view (SQL summerer cachede matchpoeng; motoren beregner dem) +-- ===================================================================== +-- security_invoker => viewet kjører med den spørrende rollens RLS, ikke eierens. +-- Uten dette kunne standings lekke matcher på tvers av organisasjoner. +CREATE VIEW tournament_standings + WITH (security_invoker = true) AS +SELECT + t.organization_id, + t.tournament_id, + t.id AS team_id, + t.name AS team_name, + COALESCE(SUM( + CASE WHEN m.team_a_id = t.id THEN m.points_side_a + WHEN m.team_b_id = t.id THEN m.points_side_b + ELSE 0 END + ), 0) AS points +FROM team t +LEFT JOIN match m + ON m.organization_id = t.organization_id + AND (m.team_a_id = t.id OR m.team_b_id = t.id) +GROUP BY t.organization_id, t.tournament_id, t.id, t.name; + + +-- ===================================================================== +-- 9. updated_at-trigger (valgfritt mønster — vist på to tabeller) +-- ===================================================================== +CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_tournament_updated + BEFORE UPDATE ON tournament + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TRIGGER trg_match_updated + BEFORE UPDATE ON match + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- (Samme mønster kan legges på øvrige tabeller med updated_at.) diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md new file mode 100644 index 0000000..aedb647 --- /dev/null +++ b/ARCHITECTURE_DECISIONS.md @@ -0,0 +1,202 @@ +# TeeCup — Arkitektur-beslutningslogg (ADR) + +> Dette dokumentet er den autoritative kilden til *hva* som er bestemt og *hvorfor*. +> Det leses av mennesker og av AI-modeller (Claude, Gemini) i starten av hver økt. +> Endre aldri en beslutning uten å legge til en ny ADR som erstatter den — historikken skal bevares. + +**Status:** Levende dokument +**Sist oppdatert:** 2026-07-15 + +--- + +## Kontekst + +TeeCup er en kommersiell (SaaS) webapplikasjon for å opprette, administrere og +gjennomføre golfturneringer i «Ryder Cup»-format. Den driftes på samme VPS som +`teeoff.no`, men skal fungere som et isolert økosystem med eget subdomene +(`teecup.teeoff.no`) og egen database (`teecup_db`). + +Målgruppe: klubber, bedrifter og vennegjenger som arrangerer turneringer over tid. + +--- + +## ADR-001 — Tenant = Organisasjon + +**Beslutning:** Isolasjonsenheten («tenant») er en **organisasjon**, ikke en turnering. + +En organisasjon er et bevisst nøytralt begrep som dekker klubb, bedrift *og* +vennegjeng. Ved å ikke kalle den «klubb» i datamodellen unngår vi refaktorering +den dagen første bedriftskunde kommer. + +**Hierarki:** + +``` +Organisasjon (tenant — det som isoleres og faktureres) + ├── Medlemmer/spillere (gjenbrukbare på tvers av turneringer) + ├── Egendefinerte baner + └── Turneringer + ├── Lag + └── Matcher + └── Scores +``` + +**Begrunnelse:** En turnering er en hendelse med start og slutt, ikke en kunde. +Gjenbrukbare ting (spillere, baner, historikk) må leve over turneringens levetid. +Turnering er derfor en entitet *inne i* en organisasjon. + +**Konsekvens:** `tenant_id` i alle skjemaer heter `organization_id`. + +--- + +## ADR-002 — Bruker og organisasjonsmedlemskap er adskilt + +**Beslutning:** Identitet (innlogging) og medlemskap i en organisasjon er to +forskjellige ting. Én `user` kan ha flere `organization_memberships`. + +**Begrunnelse:** Samme person spiller ofte både klubbturneringen og +jobbturneringen. En bruker må kunne krysse organisasjoner med samme innlogging. +Dette er lett å bygge inn fra start og smertefullt å legge til senere. + +**Konsekvens:** Utelukker «database-per-tenant» (se ADR-003), fordi en bruker som +krysser organisasjoner da måtte eksistere i flere databaser samtidig. + +--- + +## ADR-003 — Isolasjonsstrategi: Shared schema + Row-Level Security + +**Beslutning:** Én database (`teecup_db`), delte tabeller, isolert på +`organization_id`-kolonne, håndhevet av PostgreSQL **Row-Level Security (RLS)**. + +**Vurderte alternativer:** + +| Strategi | For | Mot | Valgt | +|---|---|---|---| +| Shared schema + `organization_id` + RLS | Enkel drift, billig, skalerer til mange org. | Krever disiplin; RLS må settes riktig | **Ja** | +| Database/schema per tenant | Sterk isolasjon | Tung migrering; bryter med ADR-002 | Nei | + +**Begrunnelse:** RLS flytter isolasjonen fra applikasjonskoden (der én glemt +`WHERE organization_id = ...` lekker data mellom kunder) ned til databasen, som +håndhever den uansett hva koden gjør. Kombinert med ADR-002 er dette det eneste +praktiske valget. + +**Konsekvens / oppgave:** Hver økt/tilkobling må sette `SET app.current_org` (eller +tilsvarende) slik at RLS-policyen kan filtrere. Dette må inn i tilkoblingslaget +tidlig, ikke ettermonteres. + +--- + +## ADR-004 — Banedata fra `teeoff_db` via enveis, lesende API-kontrakt + +**Beslutning:** TeeCup henter offisielle banedata fra hovedplattformen gjennom et +veldefinert, lesende API — **ikke** via direkte databasekobling på tvers. + +**Begrunnelse:** En direkte kobling ville låst TeeCup til hovedplattformens +skjemaendringer, og et brudd ett sted ville tatt ned begge produktene. +`teeoff_db` er «master» for offisielle banedata; alt brukergenerert innhold +(egendefinerte baner, turneringer, brukere) forblir strengt i `teecup_db`. + +**Konsekvens:** API-kontrakten mot TeeOff må versjoneres og behandles som en +ekstern avhengighet, selv om den kjører på samme server. + +--- + +## ADR-005 — Handicap-motoren er et frittstående, testet bibliotek + +**Beslutning:** All handicap- og score-logikk bygges som en ren Python-modul uten +avhengigheter til database, API eller web-rammeverk. Den testes grundig i +isolasjon. + +**Begrunnelse:** Dette er produktets hjerte og den mest risikofylte biten. Feil +her koster mest troverdighet. Ved å isolere den kan reglene enhetstestes mot kjente +fasitverdier uavhengig av resten av systemet. + +**Konsekvens / viktig presisering:** Prosentbaserte «allowances» (f.eks. 75 %, +90 %, 3/4) er **konfigurasjon, ikke hardkodet logikk**. Motoren vet ikke at +«fourball = 90 %»; den mottar tildelingen som parameter. De konkrete +standardprosentene per format må verifiseres mot gjeldende WHS/lokale regler før +produksjon — de skal ikke antas fra hukommelse. + +--- + +## ADR-006 — Teknologistack + +**Beslutning:** +- **Backend:** Python + FastAPI (gjenbruker stacken fra TeeOff → enklere vedlikehold). +- **Database:** PostgreSQL (+ PostGIS der banegeometri trengs). +- **Frontend:** React (Vite) som PWA, offline-first (Service Workers + IndexedDB). + +**Begrunnelse:** Offline-first er *kjernefunksjonalitet*, ikke luksus — golfbaner har +ofte dårlig mobildekning, og score må kunne registreres uten nett og synkes senere. + +--- + +## ADR-007 — Miksede formater via økter, og spiller-pool + +**Beslutning:** En turnering er en **ordnet sekvens av økter** (sessions), ikke ett +enkelt format. Hver økt bærer format, hullomfang og allowance. Ryder Cup = +foursome/18 → fourball/18 → singles/18 er tre økter. + +Hvert lag har en **pool** (`team_roster`) som kan være større enn antall +matchplasser. Deltakelse avgjøres per match via `match_participant`. En reserve +er en spiller i poolen uten deltaker-rad i en gitt økt; en spiller kan spille +kun enkelte økter (f.eks. bare singelen). + +**Begrunnelse:** Dette er selve Ryder Cup-strukturen. Å modellere format på +turneringsnivå ville gjort miksede formater umulig; å modellere deltakelse på +turneringsnivå ville gjort reserver og delvis deltakelse umulig. + +**Konsekvens:** Handicap-snapshot fryses per turnering i `team_roster` +(`handicap_index_snapshot`) for reproduserbare resultater. + +--- + +## ADR-008 — 9-hulls-slag: "slagene som faller på 18-hulls-kortet" + +**Beslutning:** For 9-hulls-økter (front/back) brukes spillerens fulle +18-hulls-tildeling, og slagene fordeles over **hele** 18-hulls stroke index — +deretter tas kun de spilte hullene ut. Spilleren mottar slag på de spilte hullene +der SI ≤ mottatte slag. + +**Vurdert alternativ:** WHS' formelle 9-hulls course handicap (eget 9-hulls +rating, grovt sagt halvparten). Kan gi et litt annet totaltall. Ikke valgt som +standard fordi ikke alle baner publiserer 9-hulls-ratinger, og den valgte +metoden er den vanlige i vennegjeng-/klubbmatch-spill. + +**Kritisk implementasjonsdetalj:** Man må IKKE sende bare de ni spilte hullene inn +i slagfordelingen med et 18-hulls slagtall — det gir feil ved høye slagtall +(12 slag på back-9 blir da 10 i stedet for riktige 6). Motoren har derfor +`allocate_over_played_holes(...)` som fordeler over alle 18 og så tar ut de spilte. +Dekket av test `test_nine_hole_twelve_strokes_is_six_not_ten`. + +**Konsekvens:** `tee_rating` beholder likevel front/back-omfang, slik at WHS' +alternativ kan tilbys senere som konfig per turnering uten skjemaendring. + +--- + +## Åpne spørsmål (ikke besluttet ennå) + +Disse må avklares før eller under de relevante fasene: + +1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på + `JWT_SECRET`. TeeCup bør bruke separate, uavhengige secrets. *(Sikkerhet)* +2. **Cache over flere prosesser:** In-memory `dict`-cache på `app.state` deles ikke + mellom flere workers/containere. Ved skalering trengs Redis. *(Skalering)* +3. **Prising:** Per organisasjon (abonnement) eller per turnering? Påvirker ikke + isolasjonsmodellen, men påvirker fakturerings-/kvotemodell. +4. **Scramble-grensesnitt:** Arkitekturen skal ta høyde for formatet; eksakt + UI-løsning spesifiseres senere. +5. **Individuell-vs-delt-ball i `hole_score`:** Håndheves i app-laget, ikke av + databasen (CHECK når ikke opp til `session.format`). Motoren/API-et må passe + på at f.eks. et foursome ikke får per-spiller-scorer. + +--- + +## Utviklingsplan (rekkefølge) + +1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) +2. ✅ Denne beslutningsloggen (dette dokumentet) +3. ✅ Handicap-motor som frittstående, testet bibliotek (ADR-005) — 24 tester, R&A-verifisert +4. ✅ Databaseskjema (`001_initial_schema.sql`) — RLS, miksede formater, pool +5. ⏩ Backend-API + regelmotor-integrasjon (neste) +6. ⬜ Frontend (admin + score-registrering) +7. ⬜ PWA & offline-synk diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md new file mode 100644 index 0000000..bcb1a56 --- /dev/null +++ b/FEATURE_BACKLOG.md @@ -0,0 +1,111 @@ +# TeeCup — Funksjons-backlog + +> Formål: fange ALT som er ønsket for TeeCup på ett sted, slik at intet krav går +> tapt mellom økter, modeller eller samtaler. Kilder: de to opprinnelige +> Gemini-samtalene (funksjonalitet + Docker) og arbeidet gjort med Claude. +> +> Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning +> · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst) +> +> Sist oppdatert: 2026-07-15 + +--- + +## Fundament (bygget) + +| Funksjon | Status | Notat | +|---|---|---| +| Handicap-/matchmotor (testet) | ✅ | 24 tester, R&A-verifisert. Erstatter Geminis løse JS-funksjon. | +| Formater: singles, fourball, foursome, greensome, scramble | ✅ | I motoren, allowance som konfig. | +| Brutto/netto + prosent-allowance (75 %, 90 %, 3/4) | ✅ | ADR-005. | +| 9-hulls (front/back) slagfordeling | ✅ | ADR-008, egen funksjon + test. | +| Miksede formater per turnering (økter) | ✅ | ADR-007. Ryder Cup-strukturen. | +| Spiller-pool m/ reserver, delvis deltakelse | ✅ | ADR-007. | +| Multi-tenant skjema + RLS | ✅ | ADR-001/003. Isolasjon bevist med oppførselstest. | +| Dedikert app-rolle (ingen superuser/BYPASSRLS) | ✅ | migrasjon 002. | +| API-fundament m/ trygg org-kontekst | 🔨 | Skjelett klart; CRUD gjenstår. | +| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». | + +--- + +## Ønsket, men IKKE fanget før nå (fra Gemini-samtalene) + +### Brukerroller (utover org-medlemskap) +- **Status:** ❓ trenger beslutning +- De opprinnelige samtalene beskriver: turneringsadmin, **lagkaptein**, spiller, + **tilskuer** (les-only, følger live uten skriverettigheter). +- Vi har i dag org-roller (owner/admin/member) + `is_captain` på roster. +- **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den + offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke + rettigheter (sette oppstilling), ikke bare være et flagg. + +### Blind draw (skjult lagoppstilling) +- **Status:** 📋 planlagt (i din egen utviklingsplan) +- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er + ferdige. +- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst → + avslørt» før `match_participant` gjøres synlig. Egen `lineup_submission`-modell + e.l. Ikke bygget. + +### Forenklet scoreføring (uten slagtall) +- **Status:** ❓ trenger beslutning (påvirker skjema) +- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet / + delt». +- **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren + hull-resultat-modus krever enten at slag kan være NULL + et resultatfelt, eller + en egen `match_hole_result`-tabell. Må avgjøres før scoring-API-et. + +### Bøtekasse (Kangaroo Court) +- **Status:** 📋 planlagt +- Meld inn overtramp med bøter («kastet kølla på hull 4»). +- Henger sammen med Kommunikasjon: bøter deles i feeden. Sannsynligvis en egen + post-type i meldingsmodellen. + +### Flerårig statistikk / historikk / MVP +- **Status:** 📋 planlagt (datamodell støtter det allerede) +- Seiersprosent i singelmatcher, historisk MVP, statistikk over år. +- Datamodellen tillater dette fordi spillere lever på org-nivå og gjenbrukes på + tvers av turneringer, og handicap fryses per turnering (reproduserbart). + +### Push-varsler +- **Status:** 📋 planlagt (infrastruktur) +- «BREAKING: X vant matchen». PWA push. Egen infrastruktur-bit. + +### Sanntid (WebSockets) +- **Status:** ❓ trenger beslutning +- Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard-viewet, + men ikke sanntidsleveringen. Valg: WebSockets vs. polling. + +--- + +## Kommunikasjon (under design) + +| Del | Status | Notat | +|---|---|---| +| Lag-intern chat («det hemmelige rommet») | 🔨 | Bekreftet ønsket. Kanal m/ scope `team`. | +| Offentlig runde-feed («Banter Board») | ❓ | Synlighetsnivå ikke besluttet (deltakere/org/offentlig lenke). | +| Bilder i feed/chat | 📋 | v1. Objektlagring (MinIO), presigned opplasting. | +| Video | 💤 | Arkitekt for det, bygg senere (ADR-forslag). | +| 1-til-1 direktemeldinger | 💤 | Gemini frarådet for v1; ikke etterspurt av deg. | +| Moderering (for offentlig innhold) | ❓ | Kreves hvis «alle med lenken». Mønster finnes i teeoff. | + +--- + +## UX / frontend (senere fase) + +- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp + (banebruk i sollys/med solbriller). +- ✅ Offline-first (ADR-006) — prinsipp besluttet; implementasjon senere. +- 📋 PWA: manifest, service workers, «Legg til på hjemskjerm». + +--- + +## Bevisst endret fra opprinnelige (Gemini-)råd + +- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte + DB-kobling ville låst TeeCup til teeoffs skjemaendringer. +- 🔀 **Handicap-motor:** egen testet Python-modul, ikke den innlimte JS-funksjonen + (som bl.a. ikke håndterte 9-hull eller konfigurerbare allowances korrekt). +- 🔀 **Tenant-modell:** organisasjon som tenant med RLS, ikke bare «turnering-ID». +- 🔀 **Sesjons-secret:** egne secrets for TeeCup, ikke fallback til teeoffs + (teeoff selv bruker en slik fallback — bevisst unngått her). diff --git a/__pycache__/handicap_engine.cpython-312.pyc b/__pycache__/handicap_engine.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..025aacc59df47b9a0f5c70befe15e1368898bde4 GIT binary patch literal 14504 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

%dRcwG)4G@}Hml@$aV( z1lPy!*57sC}x2poEamz!~xhDSwzxytF6B**jx6Ufm}7kUUGH2v%+qy#dn5T@%cx^9PnFhF)Wm0ilj+x>gYggq%H)VZM9+!h~^fCxgw|0m;r0y@8xm(4 zZr}3e)kE*8EA8K(%ak{pF5kLcJVP141Oty{P#fUE0EHolz}&xvUzmXlaVu^yFxi&h z72Q{~u1{@?U9H$+Q{fuWj;Sa=MF&S{@NXakRi&EqS76Mmsb7^hXMN4EoNDSoT+>k4#&C!EBhZ*esgX3ZaCBUb>#l4=TqS?2Yz`l zbLN{cxO{cFvZ`F&q1^6cx$0-|-xHtH@7&;=4ktU3zq8U{w^RknIhcY#IYVi1A?Idg zKK6eM2=iW;w9d=y5(vW?U7-6ZM&KI$AWnIPl6{mAJ1F~+Y~eLaC!jcQF+`M>nJ6Xh z-1eZ9+S&G$mW=sJQ==ScmX@+@uryAvFbN%%Sl)2lrpQ;q0&U!rAZfZpCYg z`eRt7@R|*E8(x0|a)pv^>ijS=dAzROlNI*ls&{3DUAY>D+8+ORF3`OPqeoV&G+U8DOqvf>!3?f7e8qXhu*_i_^BMP|7Og3CN$UbMGhyiV zj&a-B@=Y1H@L~T2U_ru02F{W53YRjYbdl{Cnqs>I->r+89g3>EO^SJ)pWPCT((97& zu`s5J&){me_ zsQOizbM;yp_5RSP@Gq!BrB%T=S=W_6CSp=OiWaX)p?gJiC2lf_ z%YL{Lrx4TWKI(u=70sYg;uh{I4dCKm1?0Lx0CL{U@&O@3H{v(|T>TE@-{d*E;(8P^irawYL&$(t2;UzMYlSWrVt0 zzqfWKyQ{6}J$?9ecGsDWGFKOGOLP0u2VU6ZT)b^_pNH?<6dU;JjhFZu{!Ff>e#4I6 z$ImovIPi<*+ 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)) + + +# --------------------------------------------------------------------------- +# 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]) diff --git a/test_handicap_engine.py b/test_handicap_engine.py new file mode 100644 index 0000000..8a9ade8 --- /dev/null +++ b/test_handicap_engine.py @@ -0,0 +1,310 @@ +""" +Tester for TeeCup handicap-motor. + +Fasitverdiene er hentet fra R&A Rules of Handicapping, Appendix C, der det er +mulig, slik at motoren kan verifiseres mot en autoritativ kilde uavhengig av +resten av systemet (ADR-005). + +Kjør: python -m pytest test_handicap_engine.py -v + ev. python test_handicap_engine.py (kjører en enkel selvsjekk uten pytest) +""" + +from handicap_engine import ( + Format, + HoleResult, + Player, + PerPlayerPercentage, + CombinedPercentage, + WeightedLowHigh, + RankedSplit, + DEFAULT_MATCHPLAY_ALLOWANCES, + allocate_strokes_by_index, + allocate_over_played_holes, + compute_match_state, + course_handicap, + course_handicap_raw, + match_play_strokes, + round_half_up, + unit_playing_handicap, +) + + +# --------------------------------------------------------------------------- +# Avrunding +# --------------------------------------------------------------------------- + +def test_round_half_up_positive(): + assert round_half_up(16.2) == 16 + assert round_half_up(15.3) == 15 + assert round_half_up(26.1) == 26 + assert round_half_up(0.5) == 1 # 0,5 alltid opp (ikke banker's) + assert round_half_up(2.5) == 3 + assert round_half_up(1.5) == 2 + + +def test_round_half_up_negative(): + # Minus-handicap (plusspillere) + assert round_half_up(-2.5) == -2 + assert round_half_up(-0.5) == 0 + + +# --------------------------------------------------------------------------- +# Course Handicap +# --------------------------------------------------------------------------- + +def test_course_handicap_formula(): + # Index 18.0, Slope 113 (nøytral), CR == Par -> nøyaktig 18 + assert course_handicap_raw(18.0, 113, 72.0, 72) == 18.0 + # Slope 130, CR 71.5, Par 72 + raw = course_handicap_raw(10.0, 130, 71.5, 72) + assert abs(raw - (10.0 * 130 / 113 + (71.5 - 72))) < 1e-9 + assert course_handicap(10.0, 130, 71.5, 72) == round_half_up(raw) + + +# --------------------------------------------------------------------------- +# Singles match play (R&A Appendix C, Eksempel 2): 100 % +# A spiller av 0, B mottar 8 slag. +# --------------------------------------------------------------------------- + +def test_singles_match_play_appendix_c_example_2(): + strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.SINGLES] + # To spillere med course handicap som skiller 8 (100 % allowance) + a = Player("A", 8.0, 113, 72.0, 72) # CH 8 + b = Player("B", 16.0, 113, 72.0, 72) # CH 16 + ph_a = unit_playing_handicap([a], strat) + ph_b = unit_playing_handicap([b], strat) + strokes = match_play_strokes([ph_a, ph_b]) + assert strokes == [0, 8] + + +# --------------------------------------------------------------------------- +# Four-ball match play (R&A Appendix C, Eksempel 3): 90 % per spiller +# Course handicaps 10 / 18 / 27 / 39 -> 0 / 7 / 15 / 26 +# --------------------------------------------------------------------------- + +def test_fourball_match_play_appendix_c_example_3(): + strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.FOURBALL] + chs = [10, 18, 27, 39] + players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate(chs)] + phs = [unit_playing_handicap([p], strat) for p in players] + # 90 % avrundet: 9, 16, 24, 35 + assert phs == [9, 16, 24, 35] + strokes = match_play_strokes(phs) + assert strokes == [0, 7, 15, 26] + + +# --------------------------------------------------------------------------- +# Foursomes match play (R&A Appendix C, Eksempel 4): 50 % av differansen +# mellom lagenes samlede course handicap. Team 2 mottar 19. +# -> lagenes samlede CH skiller 38. +# --------------------------------------------------------------------------- + +def test_foursome_match_play_appendix_c_example_4(): + strat = DEFAULT_MATCHPLAY_ALLOWANCES[Format.FOURSOME] + # Team 1 samlet CH = 20, Team 2 samlet CH = 58 -> differanse 38 + team1 = [Player("A", 8.0, 113, 72.0, 72), Player("B", 12.0, 113, 72.0, 72)] # sum 20 + team2 = [Player("C", 28.0, 113, 72.0, 72), Player("D", 30.0, 113, 72.0, 72)] # sum 58 + ph1 = unit_playing_handicap(team1, strat) # 50 % av 20 = 10 + ph2 = unit_playing_handicap(team2, strat) # 50 % av 58 = 29 + assert ph1 == 10 and ph2 == 29 + strokes = match_play_strokes([ph1, ph2]) + assert strokes == [0, 19] + + +# --------------------------------------------------------------------------- +# Greensomes: 60 % laveste + 40 % høyeste +# --------------------------------------------------------------------------- + +def test_greensome_weighted_allowance(): + strat = WeightedLowHigh(0.60, 0.40) + # CH 12 og 20 -> 0,6*12 + 0,4*20 = 7,2 + 8,0 = 15,2 -> 15 + p_low = Player("L", 12.0, 113, 72.0, 72) + p_high = Player("H", 20.0, 113, 72.0, 72) + assert unit_playing_handicap([p_low, p_high], strat) == 15 + # rekkefølge skal ikke spille noen rolle + assert unit_playing_handicap([p_high, p_low], strat) == 15 + + +# --------------------------------------------------------------------------- +# Scramble: rangert splitt +# --------------------------------------------------------------------------- + +def test_scramble_4_ranked_split(): + strat = RankedSplit((0.25, 0.20, 0.15, 0.10)) + # CH 4, 10, 16, 24 -> 0,25*4 + 0,20*10 + 0,15*16 + 0,10*24 + # = 1,0 + 2,0 + 2,4 + 2,4 = 7,8 -> 8 + players = [Player(f"P{i}", ch, 113, 72.0, 72) for i, ch in enumerate([24, 4, 16, 10])] + assert unit_playing_handicap(players, strat) == 8 # rekkefølge irrelevant + + +def test_scramble_2_ranked_split(): + strat = RankedSplit((0.35, 0.15)) + # CH 6 og 18 -> 0,35*6 + 0,15*18 = 2,1 + 2,7 = 4,8 -> 5 + players = [Player("A", 18.0, 113, 72.0, 72), Player("B", 6.0, 113, 72.0, 72)] + assert unit_playing_handicap(players, strat) == 5 + + +def test_allowance_is_configurable_not_hardcoded(): + """ADR-005: motoren skal godta en overstyrt allowance (f.eks. 75 %/3/4).""" + strat_75 = PerPlayerPercentage(0.75) + p = Player("X", 20.0, 113, 72.0, 72) # CH 20 + assert unit_playing_handicap([p], strat_75) == 15 # 0,75*20 + + +# --------------------------------------------------------------------------- +# Slagfordeling på Stroke Index +# --------------------------------------------------------------------------- + +def test_allocate_strokes_basic(): + si = list(range(1, 19)) # SI 1..18 + # 5 slag -> ett slag på SI 1..5, null ellers + alloc = allocate_strokes_by_index(5, si) + assert sum(alloc) == 5 + assert alloc[0] == 1 and alloc[4] == 1 and alloc[5] == 0 + + +def test_allocate_strokes_high_handicap_double(): + si = list(range(1, 19)) + # 20 slag -> alle hull minst 1, SI 1 og 2 får 2 + alloc = allocate_strokes_by_index(20, si) + assert sum(alloc) == 20 + assert alloc[0] == 2 and alloc[1] == 2 and alloc[2] == 1 + + +def test_allocate_strokes_zero(): + si = list(range(1, 19)) + assert allocate_strokes_by_index(0, si) == [0] * 18 + + +def test_allocate_strokes_plus_handicap_gives_back(): + si = list(range(1, 19)) + # -2 slag: gir tilbake på de to letteste hullene (SI 18 og 17) + alloc = allocate_strokes_by_index(-2, si) + assert sum(alloc) == -2 + # SI 18 er indeks 17, SI 17 er indeks 16 + assert alloc[17] == -1 and alloc[16] == -1 + assert alloc[0] == 0 + + +def test_allocate_strokes_respects_scorecard_order(): + # Hullenes SI i kortrekkefølge (ikke sortert): fordelingen skal følge SI-verdien + si = [5, 1, 12, 3, 18, 7, 9, 11, 15, 2, 4, 6, 8, 10, 13, 14, 16, 17] + alloc = allocate_strokes_by_index(3, si) + assert sum(alloc) == 3 + # Slag skal ligge på hull med SI 1, 2, 3 + for hole_si, strokes in zip(si, alloc): + assert strokes == (1 if hole_si <= 3 else 0) + + +# --------------------------------------------------------------------------- +# 9-hulls-fordeling (front/back) — "slagene faller på 18-hulls-kortet" +# --------------------------------------------------------------------------- + +# Standard 18-hulls stroke index i hullrekkefølge: hull 1 har SI 1, hull 2 SI 3, ... +# Odde SI på front-9, par SI på back-9. +_FRONT_ODD_SI = [1, 3, 5, 7, 9, 11, 13, 15, 17] +_BACK_EVEN_SI = [2, 4, 6, 8, 10, 12, 14, 16, 18] +_ALL18_SI = _FRONT_ODD_SI + _BACK_EVEN_SI # hull 1..18 +_FRONT_HOLES = list(range(1, 10)) # hull 1..9 +_BACK_HOLES = list(range(10, 19)) # hull 10..18 + + +def test_nine_hole_three_strokes_back_vs_front(): + # 3 mottatte slag: faller på SI 1, 2, 3. + back = allocate_over_played_holes(3, _ALL18_SI, _BACK_HOLES) + front = allocate_over_played_holes(3, _ALL18_SI, _FRONT_HOLES) + assert sum(back) == 1 # kun SI 2 på back-9 + assert sum(front) == 2 # SI 1 og 3 på front-9 + + +def test_nine_hole_twelve_strokes_is_six_not_ten(): + # Kjernetesten: 12 slag på back-9 skal bli 6, ikke 10 (den naive feilen). + back = allocate_over_played_holes(12, _ALL18_SI, _BACK_HOLES) + assert sum(back) == 6 + # SI 2,4,6,8,10,12 får slag; SI 14,16,18 får ikke. + assert back == [1, 1, 1, 1, 1, 1, 0, 0, 0] + + +def test_nine_hole_matches_naive_only_when_low(): + # Metodene sammenfaller så lenge totalen ikke overstiger antall spilte hull + # (her 9): opp til 8 er base-slaget i den naive varianten fortsatt 0. + for total in range(0, 9): + correct = sum(allocate_over_played_holes(total, _ALL18_SI, _BACK_HOLES)) + naive = sum(allocate_strokes_by_index(total, _BACK_EVEN_SI)) + assert correct == naive + # Fra og med 9 spriker de: + assert sum(allocate_over_played_holes(12, _ALL18_SI, _BACK_HOLES)) \ + != sum(allocate_strokes_by_index(12, _BACK_EVEN_SI)) + + +# --------------------------------------------------------------------------- +# Match-status +# --------------------------------------------------------------------------- + +def test_match_state_all_square(): + results = [HoleResult.SIDE_A, HoleResult.SIDE_B, HoleResult.HALVED] + state = compute_match_state(results, total_holes=18) + assert state.lead == 0 + assert state.describe() == "AS" + + +def test_match_state_two_up(): + results = [HoleResult.SIDE_A, HoleResult.SIDE_A, HoleResult.HALVED] + state = compute_match_state(results, total_holes=18) + assert state.lead == 2 + assert state.describe() == "2 UP (A)" + + +def test_match_state_dormie(): + # A leder med 2, og det gjenstår nøyaktig 2 hull + results = [HoleResult.SIDE_A] * 2 + [HoleResult.HALVED] * 14 + state = compute_match_state(results, total_holes=18) + assert state.holes_remaining == 2 + assert state.is_dormie is True + assert state.describe() == "dormie 2 (A)" + + +def test_match_state_closed_3_and_2(): + # A leder med 3 etter 16 hull -> 2 gjenstår -> avgjort "3&2" + results = [HoleResult.SIDE_A] * 3 + [HoleResult.HALVED] * 13 + state = compute_match_state(results, total_holes=18) + assert state.is_closed is True + assert state.describe() == "3&2 (A)" + + +def test_match_state_won_on_last_hole(): + # A leder med 1 etter 18 hull -> vunnet "1 UP" + results = [HoleResult.SIDE_A] + [HoleResult.HALVED] * 17 + state = compute_match_state(results, total_holes=18) + assert state.holes_remaining == 0 + assert state.describe() == "1 UP (A)" + + +def test_match_state_side_b_leads(): + results = [HoleResult.SIDE_B, HoleResult.SIDE_B, HoleResult.SIDE_A] + state = compute_match_state(results, total_holes=18) + assert state.lead == -1 + assert state.describe() == "1 UP (B)" + + +# --------------------------------------------------------------------------- +# Enkel selvsjekk uten pytest +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + import traceback + + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + passed = 0 + failed = 0 + for t in tests: + try: + t() + print(f" ok {t.__name__}") + passed += 1 + except Exception: + print(f" FEIL {t.__name__}") + traceback.print_exc() + failed += 1 + print(f"\n{passed} bestått, {failed} feilet, {len(tests)} totalt") + raise SystemExit(1 if failed else 0)