Første commit for teecup

This commit is contained in:
Erol Haagenrud 2026-07-16 07:18:01 +02:00
commit 5873db18d1
7 changed files with 1390 additions and 0 deletions

View file

@ -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/**)"
]
}
}

410
001_initial_schema.sql Normal file
View file

@ -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 = '<organisasjonens uuid>';
-- 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 = '<uuid>';
-- 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.)

202
ARCHITECTURE_DECISIONS.md Normal file
View file

@ -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

111
FEATURE_BACKLOG.md Normal file
View file

@ -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).

Binary file not shown.

338
handicap_engine.py Normal file
View file

@ -0,0 +1,338 @@
"""
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 decimal import Decimal, ROUND_FLOOR
from enum import Enum
from typing import 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))
# ---------------------------------------------------------------------------
# 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 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 18 hull får 1
slag overalt + 1 ekstra SI 1 og 2 -> 2 slag der).
- Håndterer minus-handicap (total < 0): spilleren GIR slag tilbake,
med start 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 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 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 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])

310
test_handicap_engine.py Normal file
View file

@ -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)