Remplace la Course du Char par Le Vol d'Icare
Build and deploy / deploy (push) Successful in 37s

Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
This commit is contained in:
Valentin ROBIN
2026-07-29 19:14:52 +02:00
parent 6af56d2ade
commit 33d2476ef6
22 changed files with 636 additions and 1054 deletions
+83 -86
View File
@@ -485,145 +485,142 @@ 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.
-- 11. Le Vol d'Icare -----------------------------------------------------------
-- Mini-jeu : remplace la Course du Char (abandonnée — voir nettoyage
-- ci-dessous). Flappy Bird grec : Icare vole entre des colonnes de temple,
-- un seul record personnel all-time (pas de piste/jour, générée librement à
-- chaque partie côté client). Les scores sont figés dès que la date du
-- Tribunal (settings.tribunal_date) est atteinte, puis les gloires du top 3
-- sont attribuées automatiquement (pas d'Archonte impliqué) par une tâche
-- planifiée pg_cron.
-- 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;
-- Nettoyage de la Course du Char (schéma déjà appliqué en prod avant cet
-- abandon — ces DROP sont nécessaires, pas juste cosmétiques).
do $$
begin
perform cron.unschedule('close-daily-chariot-race');
exception
when others then null;
end $$;
drop function if exists public.close_daily_chariot_race();
drop table if exists public.chariot_race_closes;
drop function if exists public.submit_chariot_run(integer, jsonb);
drop table if exists public.chariot_runs;
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)
-- Un point attribué automatiquement n'a pas de juge : judge_id reste
-- nullable (déjà appliqué précédemment — le Crieur affiche "Le Tribunal").
create table if not exists public.icarus_scores (
user_id uuid primary key references public.profiles (id) on delete cascade,
best_score integer not null check (best_score between 0 and 1000000),
updated_at timestamptz not null default now()
);
create index if not exists chariot_runs_race_date_idx
on public.chariot_runs (race_date, best_time_ms);
alter table public.icarus_scores enable row level security;
alter table public.chariot_runs enable row level security;
-- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule
-- la RPC submit_icarus_score() (SECURITY DEFINER) peut écrire.
revoke insert, update, delete on public.icarus_scores from authenticated, anon;
grant select on public.icarus_scores to authenticated;
-- 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
drop policy if exists "icarus_scores readable by authenticated" on public.icarus_scores;
create policy "icarus_scores readable by authenticated"
on public.icarus_scores 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
-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul
-- événement, pas de notion de jour comme pour la Course du Char).
alter table public.settings add column if not exists icarus_points_awarded boolean not null default false;
-- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal
-- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le
-- record existant sans le modifier) plutôt que d'échouer bruyamment.
create or replace function public.submit_icarus_score(p_score integer)
returns public.icarus_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_race_date date;
v_row public.chariot_runs;
v_tribunal_date timestamptz;
v_row public.icarus_scores;
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';
if p_score is null or p_score < 0 or p_score > 1000000 then
raise exception 'invalid score';
end if;
v_race_date := (now() at time zone 'Europe/Paris')::date;
select tribunal_date into v_tribunal_date from public.settings where id = true;
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;
if v_tribunal_date is not null and now() >= v_tribunal_date then
select * into v_row from public.icarus_scores where user_id = auth.uid();
return v_row;
end if;
-- 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;
insert into public.icarus_scores (user_id, best_score, updated_at)
values (auth.uid(), p_score, now())
on conflict (user_id) do update
set best_score = excluded.best_score,
updated_at = now()
where excluded.best_score > public.icarus_scores.best_score;
select * into v_row from public.icarus_scores where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated;
grant execute on function public.submit_icarus_score(integer) to authenticated;
do $$
begin
alter publication supabase_realtime add table public.chariot_runs;
alter publication supabase_realtime add table public.icarus_scores;
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()
-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la
-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore
-- passée, et no-op définitif une fois déjà fait (icarus_points_awarded).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
create or replace function public.award_icarus_points_if_due()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1);
v_tribunal_date timestamptz;
v_already_awarded boolean;
r record;
v_points int;
begin
if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then
select tribunal_date, icarus_points_awarded into v_tribunal_date, v_already_awarded
from public.settings where id = true;
if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded 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 user_id, best_score,
rank() over (order by best_score desc) as rnk
from public.icarus_scores
)
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 || ')');
values (r.user_id, null, v_points, 'Le Vol d''Icare — rang ' || r.rnk || ' au Tribunal');
end loop;
insert into public.chariot_race_closes (race_date) values (v_target_date);
update public.settings set icarus_points_awarded = true where id = true;
end;
$$;
@@ -635,7 +632,7 @@ $$;
-- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer.
do $$
begin
perform cron.unschedule('close-daily-chariot-race');
perform cron.unschedule('award-icarus-points');
exception
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
end $$;
@@ -643,11 +640,11 @@ end $$;
do $$
begin
perform cron.schedule(
'close-daily-chariot-race',
'award-icarus-points',
'*/15 * * * *',
$cron$select public.close_daily_chariot_race();$cron$
$cron$select public.award_icarus_points_if_due();$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.';
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier l''attribution automatique des gloires du Vol d''Icare.';
end $$;