Ajoute le schéma de La Course du Char (tables, RPC, clôture auto)

- chariot_runs : meilleur temps + tracé fantôme par joueur et par jour,
  verrouillée comme points_log (écriture uniquement via RPC).
- submit_chariot_run() : calcule la date côté serveur, borne le temps,
  n'écrase que si strictement meilleur.
- close_daily_chariot_race() + chariot_race_closes : clôture la journée
  précédente et attribue les gloires du top 3 automatiquement, sans
  intervention d'un Archonte — planifiée via pg_cron (à activer une
  fois côté Dashboard Supabase).
- points_log.judge_id devient nullable (un point auto n'a pas de juge) ;
  Le Crieur affiche "Le Tribunal" pour ces décrets-là.
This commit is contained in:
Valentin ROBIN
2026-07-27 18:53:45 +02:00
parent f462cff8a7
commit 777d9ebaea
2 changed files with 170 additions and 2 deletions
+167
View File
@@ -484,3 +484,170 @@ end $$;
--
-- (Récupérer l'uuid via Authentication → Users, ou :
-- select id, pseudo from public.profiles;)
-- 11. La Course du Char --------------------------------------------------------
-- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement
-- par jour (calculée côté client à partir de la date Europe/Paris, jamais
-- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du
-- jour compte. Les points du jour sont attribués automatiquement (pas
-- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de
-- points de tout le projet qui n'attend pas de décision d'un juge.
-- Un point attribué automatiquement n'a pas de juge : judge_id devient
-- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo).
alter table public.points_log alter column judge_id drop not null;
create table if not exists public.chariot_runs (
user_id uuid not null references public.profiles (id) on delete cascade,
race_date date not null,
best_time_ms integer not null check (best_time_ms between 2000 and 300000),
ghost_path jsonb not null default '[]'::jsonb,
updated_at timestamptz not null default now(),
primary key (user_id, race_date)
);
create index if not exists chariot_runs_race_date_idx
on public.chariot_runs (race_date, best_time_ms);
alter table public.chariot_runs enable row level security;
-- Même modèle que points_log : verrouillée en écriture, seule la RPC
-- submit_chariot_run() (SECURITY DEFINER) peut écrire.
revoke insert, update, delete on public.chariot_runs from authenticated, anon;
grant select on public.chariot_runs to authenticated;
drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs;
create policy "chariot_runs readable by authenticated"
on public.chariot_runs for select
to authenticated
using (true);
-- Seul point d'entrée pour soumettre un temps : la date du jour est calculée
-- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre
-- "pour" un autre jour), le temps est borné grossièrement (anti-triche
-- minimal, suffisant pour un jeu entre amis — pas de vérification fine par
-- piste), et on n'écrase le temps existant que s'il est strictement meilleur.
create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb)
returns public.chariot_runs
language plpgsql
security definer
set search_path = public
as $$
declare
v_race_date date;
v_row public.chariot_runs;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then
raise exception 'invalid time_ms';
end if;
v_race_date := (now() at time zone 'Europe/Paris')::date;
insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at)
values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now())
on conflict (user_id, race_date) do update
set best_time_ms = excluded.best_time_ms,
ghost_path = excluded.ghost_path,
updated_at = now()
where excluded.best_time_ms < public.chariot_runs.best_time_ms;
-- On resélectionne toujours plutôt que de dépendre du RETURNING de
-- l'upsert : si la clause WHERE bloque la mise à jour (temps pas
-- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les
-- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non.
select * into v_row from public.chariot_runs
where user_id = auth.uid() and race_date = v_race_date;
return v_row;
end;
$$;
grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated;
do $$
begin
alter publication supabase_realtime add table public.chariot_runs;
exception
when duplicate_object then null;
end $$;
-- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de
-- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule
-- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire
-- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite
-- par le client.
create table if not exists public.chariot_race_closes (
race_date date primary key,
closed_at timestamptz not null default now()
);
alter table public.chariot_race_closes enable row level security;
revoke all on public.chariot_race_closes from authenticated, anon;
-- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs,
-- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise
-- dans points_log avec judge_id = null, puis marque la date comme close.
-- No-op si déjà clôturée. Volontairement pas de grant execute à
-- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor.
create or replace function public.close_daily_chariot_race()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1);
r record;
v_points int;
begin
if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then
return;
end if;
for r in
with ranked as (
select user_id, best_time_ms,
rank() over (order by best_time_ms asc) as rnk
from public.chariot_runs
where race_date = v_target_date
)
select * from ranked where rnk <= 3
loop
v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end;
update public.profiles set points = points + v_points where id = r.user_id;
insert into public.points_log (target_id, judge_id, delta, reason)
values (r.user_id, null, v_points,
'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')');
end loop;
insert into public.chariot_race_closes (race_date) values (v_target_date);
end;
$$;
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
-- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce
-- fichier, et pas garanti self-service selon le plan/la région du projet.
-- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est
-- pas encore activée (schéma "cron" inexistant) : le reste de ce script,
-- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer.
do $$
begin
perform cron.unschedule('close-daily-chariot-race');
exception
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
end $$;
do $$
begin
perform cron.schedule(
'close-daily-chariot-race',
'*/15 * * * *',
$cron$select public.close_daily_chariot_race();$cron$
);
exception
when others then
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.';
end $$;