Ajoute Le Jeu de l'Agora : Le Char et Le Gardien du Silence
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
Page /char (16:9, projetée) pour piloter les deux jeux physiques du Tribunal : Le Char (2 colonnes de 3 emplacements côte à côte avec un VS et une illustration de char vu du dessus, sélection en un clic, duels, compteur et classement des passages) et Le Gardien du Silence (tirage aléatoire parmi les Citoyens, minuteur avec reroll auto et manuel). Banque de questions gérée séparément sur /char/questions (pensée pour être pilotée depuis un téléphone pendant que /char est projetée depuis un ordinateur).
This commit is contained in:
@@ -648,3 +648,216 @@ 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 l''attribution automatique des gloires du Vol d''Icare.';
|
||||
end $$;
|
||||
|
||||
-- 12. Le Jeu de l'Agora — Le Char et Le Gardien du Silence ---------------------
|
||||
-- Outils pour la soirée du Tribunal elle-même (jeu physique/social, pas un
|
||||
-- mini-jeu numérique) : l'app affiche/synchronise un état que les Archontes
|
||||
-- pilotent à la main, elle ne calcule/arbitre jamais le déroulé du jeu.
|
||||
-- Préfixe chariot_* (pas char_*) : sans rapport avec l'ancienne Course du
|
||||
-- Char digitale déjà supprimée (section 11), qui utilisait déjà ce préfixe —
|
||||
-- le reprendre ici évite de laisser croire à une résurrection de ce mini-jeu.
|
||||
-- La route reste /char.
|
||||
|
||||
create table if not exists public.chariot_questions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
text text not null,
|
||||
position integer not null default 0,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.chariot_questions enable row level security;
|
||||
grant select, insert, update, delete on public.chariot_questions to authenticated;
|
||||
|
||||
drop policy if exists "chariot_questions viewable by authenticated" on public.chariot_questions;
|
||||
create policy "chariot_questions viewable by authenticated"
|
||||
on public.chariot_questions for select to authenticated using (true);
|
||||
|
||||
drop policy if exists "chariot_questions insert by judges" on public.chariot_questions;
|
||||
create policy "chariot_questions insert by judges" on public.chariot_questions for insert to authenticated
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "chariot_questions update by judges" on public.chariot_questions;
|
||||
create policy "chariot_questions update by judges" on public.chariot_questions for update to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
drop policy if exists "chariot_questions delete by judges" on public.chariot_questions;
|
||||
create policy "chariot_questions delete by judges" on public.chariot_questions for delete to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.chariot_questions;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Nettoyage de l'ancien modèle par statut (team_a/team_b/out), remplacé par
|
||||
-- un modèle par emplacement fixe ci-dessous — schéma jamais appliqué en
|
||||
-- prod (retour utilisateur avant la première vraie migration), DROP direct.
|
||||
drop table if exists public.chariot_roster;
|
||||
|
||||
-- Char : 6 emplacements fixes (1-3 = Colonne A, 4-6 = Colonne B), vides
|
||||
-- (user_id null) ou occupés. Pré-remplis une fois pour toutes ci-dessous —
|
||||
-- on ne fait jamais que les UPDATE, jamais d'insert/delete côté client.
|
||||
create table if not exists public.chariot_slots (
|
||||
slot integer primary key check (slot between 1 and 6),
|
||||
user_id uuid references public.profiles (id) on delete set null
|
||||
);
|
||||
insert into public.chariot_slots (slot)
|
||||
select generate_series(1, 6)
|
||||
on conflict (slot) do nothing;
|
||||
|
||||
alter table public.chariot_slots enable row level security;
|
||||
grant select, update on public.chariot_slots to authenticated;
|
||||
|
||||
drop policy if exists "chariot_slots viewable by authenticated" on public.chariot_slots;
|
||||
create policy "chariot_slots viewable by authenticated"
|
||||
on public.chariot_slots for select to authenticated using (true);
|
||||
|
||||
drop policy if exists "chariot_slots update by judges" on public.chariot_slots;
|
||||
create policy "chariot_slots update by judges" on public.chariot_slots for update to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.chariot_slots;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Journal d'entrées dans le char (append-only, comme points_log) : sert
|
||||
-- uniquement à compter combien de fois chaque personne est montée —
|
||||
-- une ligne ajoutée à chaque affectation d'un emplacement, jamais modifiée.
|
||||
create table if not exists public.chariot_entries (
|
||||
id bigint generated always as identity primary key,
|
||||
user_id uuid not null references public.profiles (id) on delete cascade,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.chariot_entries enable row level security;
|
||||
grant select, insert on public.chariot_entries to authenticated;
|
||||
revoke update, delete on public.chariot_entries from authenticated, anon;
|
||||
|
||||
drop policy if exists "chariot_entries viewable by authenticated" on public.chariot_entries;
|
||||
create policy "chariot_entries viewable by authenticated"
|
||||
on public.chariot_entries for select to authenticated using (true);
|
||||
|
||||
drop policy if exists "chariot_entries insert by judges" on public.chariot_entries;
|
||||
create policy "chariot_entries insert by judges" on public.chariot_entries for insert to authenticated
|
||||
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.chariot_entries;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Question actuellement révélée sur la page publique (bascule afficher/
|
||||
-- masquer), et état du Gardien du Silence.
|
||||
alter table public.settings add column if not exists chariot_revealed_question_id uuid references public.chariot_questions (id) on delete set null;
|
||||
alter table public.settings add column if not exists gardien_holder_id uuid references public.profiles (id) on delete set null;
|
||||
alter table public.settings add column if not exists gardien_expires_at timestamptz;
|
||||
|
||||
-- Verrou de colonne pour le Gardien (même pattern que profiles.points) :
|
||||
-- gardien_holder_id/gardien_expires_at ne doivent changer que via un vrai
|
||||
-- tirage aléatoire (reroll_gardien), jamais par une écriture directe même
|
||||
-- par un Archonte — chariot_revealed_question_id reste, lui, directement
|
||||
-- modifiable par les juges (rien à protéger dans cette colonne-là).
|
||||
create or replace function public.enforce_settings_update()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
bypass boolean := coalesce(current_setting('app.bypass_gardien_lock', true), 'off') = 'on';
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
if not bypass and (
|
||||
new.gardien_holder_id is distinct from old.gardien_holder_id
|
||||
or new.gardien_expires_at is distinct from old.gardien_expires_at
|
||||
) then
|
||||
raise exception 'gardien_holder_id/gardien_expires_at can only change via reroll_gardien()';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists settings_before_update on public.settings;
|
||||
create trigger settings_before_update
|
||||
before update on public.settings
|
||||
for each row execute function public.enforce_settings_update();
|
||||
|
||||
-- Tire un nouveau Gardien (uniquement parmi les Citoyens, jamais le
|
||||
-- détenteur actuel si possible) et repousse l'expiration de 5 minutes.
|
||||
-- p_force=true (réservé aux juges) reroll immédiatement ; p_force=false
|
||||
-- (utilisé par le minuteur côté client, et par pg_cron en filet) ne fait
|
||||
-- rien tant que gardien_expires_at n'est pas atteint.
|
||||
--
|
||||
-- Un seul UPDATE ... WHERE atomique (pas de SELECT puis UPDATE séparés) :
|
||||
-- avec plusieurs téléphones ouverts, tous les minuteurs locaux arrivent à
|
||||
-- zéro dans la même seconde et appellent cette RPC en même temps — la garde
|
||||
-- dans le WHERE fait que Postgres sérialise naturellement, un seul appel
|
||||
-- modifie réellement la ligne, les autres ne touchent aucune ligne.
|
||||
create or replace function public.reroll_gardien(p_force boolean default false)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller_role text;
|
||||
begin
|
||||
if p_force then
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
if caller_role is distinct from 'judge' then
|
||||
raise exception 'only judges can force a reroll';
|
||||
end if;
|
||||
end if;
|
||||
|
||||
perform set_config('app.bypass_gardien_lock', 'on', true);
|
||||
|
||||
update public.settings
|
||||
set gardien_holder_id = coalesce(
|
||||
(select id from public.profiles
|
||||
where role = 'public' and id is distinct from settings.gardien_holder_id
|
||||
order by random() limit 1),
|
||||
(select id from public.profiles where role = 'public' order by random() limit 1)
|
||||
),
|
||||
gardien_expires_at = now() + interval '5 minutes'
|
||||
where id = true
|
||||
and (p_force or gardien_expires_at is null or now() >= gardien_expires_at);
|
||||
|
||||
perform set_config('app.bypass_gardien_lock', 'off', true);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.reroll_gardien(boolean) to authenticated;
|
||||
|
||||
-- ⚠️ Même remarque que pour pg_cron plus haut : à activer une fois via le
|
||||
-- Dashboard Supabase si ce n'est pas déjà fait. Cadence 1 minute : filet
|
||||
-- large par rapport au minuteur de 5 minutes, le déclenchement réel se fait
|
||||
-- côté client dès qu'un onglet est ouvert.
|
||||
do $$
|
||||
begin
|
||||
perform cron.unschedule('reroll-gardien');
|
||||
exception
|
||||
when others then null;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
perform cron.schedule(
|
||||
'reroll-gardien',
|
||||
'*/1 * * * *',
|
||||
$cron$select public.reroll_gardien();$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 le reroll automatique du Gardien.';
|
||||
end $$;
|
||||
|
||||
Reference in New Issue
Block a user