Første commit for teecup
This commit is contained in:
commit
5873db18d1
7 changed files with 1390 additions and 0 deletions
19
.claude/settings.local.json
Normal file
19
.claude/settings.local.json
Normal 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
410
001_initial_schema.sql
Normal 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
202
ARCHITECTURE_DECISIONS.md
Normal 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
111
FEATURE_BACKLOG.md
Normal 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).
|
||||||
BIN
__pycache__/handicap_engine.cpython-312.pyc
Normal file
BIN
__pycache__/handicap_engine.cpython-312.pyc
Normal file
Binary file not shown.
338
handicap_engine.py
Normal file
338
handicap_engine.py
Normal 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 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])
|
||||||
310
test_handicap_engine.py
Normal file
310
test_handicap_engine.py
Normal 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)
|
||||||
Loading…
Reference in a new issue