diff --git a/CLAUDE.md b/CLAUDE.md index 63ac929..7666d1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V11 sont livrées : +V1 à V12 sont livrées : - **V1** : comptes, profils, leaderboard statique. - **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin. - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. @@ -19,6 +19,8 @@ V1 à V11 sont livrées : - **V10** : **L'Urne de l'Agora** (`/urne`) — vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque Citoyen dépose un jeton par jour sur une personne de son choix (jamais lui-même), confirmation à deux temps avant l'envoi (vote définitif pour la journée, aucune RPC de modification). Contrairement au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un classement public et cumulatif — **identique pour tout le monde, Archontes compris** : contraste volontaire avec le Mur, ici les Archontes n'ont aucune vision privilégiée sur qui a voté pour qui, seul le total par personne est public. Les Archontes ne votent pas et ne peuvent pas recevoir de jetons. - **V11** : deuxième mini-jeu compétitif, **La Corne d'Abondance** (`/corne`) — jeu de fusion façon « Watermelon Game » : des avatars de Citoyens tombent dans un bac (physique gérée par `matter-js`, seule dépendance de ce type dans le projet — l'empilement stable de cercles est notoirement difficile à obtenir sans bugs de superposition/tremblement en physique artisanale), deux avatars de même taille qui se touchent fusionnent en un plus gros, jusqu'au débordement soutenu (5 secondes de délai de grâce, pour ne pas sanctionner un simple rebond) qui met fin à la partie — les pièces qui dépassent la ligne de danger clignotent (`.animate-danger-blink`) pour signaler que le compte à rebours est en cours. **Un palier de taille par Citoyen** (pas les Archontes) plutôt qu'un nombre de paliers arbitraire (`buildTierRadii`, `src/lib/melon/constants.ts`) : `citizens[tier]` (indexation directe, sans tirage) donne l'association palier → Citoyen, **fixe pour tout le monde** (pas juste pour une partie) puisque `corne/page.tsx` trie déjà les Citoyens par pseudo, un ordre déterministe. `TIER_OVERRIDES` (`src/lib/melon/constants.ts`, id de profil → index de palier) permet de fixer certains Citoyens à une taille précise à la main ; `applyTierOverrides()` les place d'abord, puis remplit le reste dans l'ordre alphabétique. Même mécanique de gloires que Le Vol d'Icare : record personnel all-time, scores figés à la date du Tribunal, gloires du top 3 attribuées automatiquement (`pg_cron`, sans intervention d'un Archonte) — n'importe qui (Archontes compris) peut jouer, seul le pool d'avatars affichés est limité aux Citoyens. Rendu en DOM (pas canvas) pour réutiliser tel quel le composant `Avatar` existant (`src/components/avatar.tsx`, étendu pour accepter une taille en pixels arbitraire en plus des tailles nommées) — la position **et la rotation** de chaque pièce sont appliquées en impératif (`style.transform`, `translate(...) rotate(body.angle)`) à 60 fps depuis la boucle physique matter-js, jamais via `setState`. Le halo de l'animation d'apparition doit porter `rounded-full` lui-même, sinon son ombre portée dessine un carré visible autour du rond ; l'apparition d'une pièce utilise un simple fondu (`.animate-fade-in`) plutôt que l'animation avec mise à l'échelle utilisée ailleurs dans l'app (`.animate-reveal`), pour que sa taille exacte soit lisible dès la première frame. Trait de visée vertical + preview flottante au-dessus du bac pour montrer où la prochaine pièce va tomber, plus un second aperçu, plus petit et fixe (pas de position à viser), pour voir venir deux coups à l'avance. La bordure du bac vit sur un conteneur externe non dimensionné (pas sur le conteneur aux coordonnées physiques 300×420) : la mettre directement dessus réduisait sa zone intérieure réelle (box-sizing) sans que matter-js le sache, laissant les pièces s'empiler visuellement en dehors du cadre à droite/en bas. Sous le bac, une échelle de repère (taille d'affichage réduite mais proportionnelle au vrai rayon du palier, une seule ligne défilante, chaque rond centré sur son propre milieu plutôt qu'aligné en bas — un alignement bas avec des tailles très variables décalait progressivement la flèche par rapport aux plus gros ronds) montre l'ordre complet de fusion du plus petit au plus grand Citoyen. +- **V12** : deuxième volet de l'anti-triche des mini-jeux, ajouté par Alexandre (pas depuis cette session, appliqué directement en base puis récupéré/recommité dans `schema.sql` a posteriori — dump de schéma + `pg_policies` + `pg_get_functiondef`) et remplaçant l'approche par rejet serveur strict décrite en §3bis (`icarus_runs`/`start_icarus_run()`, désormais **supprimées**, `drop table`/`drop function` en tête de la section 11 de `schema.sql`) par un **signalement plutôt qu'un blocage** : `IcarusGame`/`MelonGame` calculent une télémétrie de partie côté client (`duration_ms`, nombre d'actions, et pour Corne le nombre de fusions — types `IcarusRunTelemetry`/`MelonRunTelemetry`) et l'envoient avec le score à `submit_icarus_score`/`submit_melon_score` (nouveau paramètre `p_run jsonb`, en plus d'une surcharge à 1 paramètre conservée pour compat qui délègue avec `p_run = null`). Le score reste **toujours accepté** (jamais de `raise exception` pour une télémétrie suspecte, contrairement à l'ancien plafond/l'ancienne vérification de temps qui restent, eux, des rejets durs) ; en parallèle, une partie est journalisée dans `public.game_cheat_flags` (`user_id, game, score, severity, trigger_code, reason, details`) dès que sa télémétrie est absente/invalide, ou incohérente avec ce que le jeu permet physiquement — pour Icare : rythme de score trop rapide (< 450 ms/point), score positif sans aucun battement d'aile, cadence de battements extrême ; pour Corne : lâchers plus rapides que `DROP_COOLDOWN_MS`, plus de fusions que de pièces lâchées ne le permettent, score dépassant le gain maximal théorique par fusion (`maxTier × 2`, la valeur du bonus de fusion finale). Nouvelle section « Détection de triche » sur `/admin` (`admin-cheat-flags.tsx`), filtrable par jeu, pour que les Archontes vérifient les signalements à la main. + Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. --- @@ -67,15 +69,16 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo - `user_id, best_score, updated_at` — clé primaire `user_id` : un seul record personnel all-time par joueur (pas de notion de jour). Verrouillée comme `points_log` : lecture ouverte aux authentifiés, écriture uniquement via la RPC `submit_icarus_score`. - Contrainte `check (best_score between 0 and 500)` sur `best_score` — la vraie garantie contre un score falsifié envoyé directement à la RPC (voir §3bis) ; `MAX_SCORE` dans `src/lib/icarus/constants.ts` n'est qu'une valeur de référence côté client, à garder alignée. -- `public.icarus_runs` (`id, user_id, started_at`) : deuxième couche anti-triche, orthogonale à la borne fixe — voir §3bis et `submit_icarus_score`. +- ~~`public.icarus_runs`~~ : deuxième couche anti-triche par rejet serveur strict, **supprimée** (`drop table`) depuis V12, remplacée par `game_cheat_flags` — voir §3bis. - `settings.icarus_points_awarded` : marqueur d'idempotence (un seul événement — l'attribution au début du Tribunal — pas une clôture quotidienne comme l'ancienne Course du Char). - Génération des colonnes/obstacles entièrement côté client, sans graine partagée (pas de piste identique pour tout le monde à reproduire ici — chaque partie est procédurale). ### `public.melon_scores` (La Corne d'Abondance) -- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis) — et **pas** de deuxième couche par temps réel écoulé comme `icarus_runs` pour l'instant (formule de score plus dure à borner analytiquement pour Corne, calibrage basé sur une estimation plutôt qu'un calcul précis). +- `user_id, best_score, updated_at` — même gabarit exact que `icarus_scores` (clé primaire `user_id`, verrouillée en écriture, uniquement via `submit_melon_score`), avec sa propre contrainte `check (best_score between 0 and 2000)` — pas la même valeur qu'Icare, l'économie de score est différente (voir §3bis). +- `public.game_cheat_flags` (`id, user_id, game, score, severity, trigger_code, reason, details jsonb, created_at`) : anti-triche commun à Icare et Corne depuis V12 (voir §1) — journalise les parties suspectes plutôt que de les bloquer, affiché aux Archontes sur `/admin`. RLS `select` réservée aux juges (même pattern que `chariot_questions`), aucun grant d'écriture pour `authenticated` (comme `points_log`/`icarus_scores`) — écrit uniquement par `submit_icarus_score`/`submit_melon_score`. - `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`. -- `submit_melon_score(p_score)` / `award_melon_points_if_due()` : copies conformes de `submit_icarus_score`/`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, et même `execute` révoqué sur la fonction cron-only — voir §3bis) — sauf la borne de score elle-même (2000, pas 500) et l'absence de vérification par temps écoulé. +- `submit_melon_score(p_score)` / `submit_melon_score(p_score, p_run)` / `award_melon_points_if_due()` : mêmes deux surcharges qu'`submit_icarus_score` (1 paramètre = compat, délègue avec `p_run = null` ; 2 paramètres = vrai point d'entrée), même borne fixe (2000, pas 500) et mêmes principes de signalement dans `game_cheat_flags` — voir V12 en §1 pour le détail des vérifications propres à Corne (cooldown de lâcher, nombre de fusions, gain max par fusion). `award_melon_points_if_due` reste une copie conforme d'`award_icarus_points_if_due` (gel à `tribunal_date`, top 3 ex-aequo inclus, `judge_id = null` dans `points_log`, tâche `pg_cron` `award-melon-points`, `execute` révoqué). - Le pool d'avatars des pièces (Citoyens uniquement, filtré côté serveur dans `corne/page.tsx` — un palier de taille par Citoyen) et les paliers/rayons du jeu vivent côté client (`src/lib/melon/constants.ts`), pas en base. N'importe qui peut jouer et apparaître dans `melon_scores`/le classement (Archontes compris) — seul le skin des pièces est restreint aux Citoyens. ### `public.chariot_questions` / `public.chariot_slots` / `public.chariot_entries` (Le Jeu de l'Agora — Le Char) @@ -112,7 +115,7 @@ PostgreSQL accorde `EXECUTE` à `PUBLIC` par défaut sur toute fonction créée, Incident réel ayant motivé cet audit : les scores d'Icare/Corne n'ont **aucune vérification de gameplay côté serveur** (génération procédurale entièrement côté client, comme documenté depuis V6/V11) — c'est un choix assumé pour un classement amical, mais l'ancien plafond (1 000 000) rendait un score falsifié, envoyé directement à `submit_icarus_score`/`submit_melon_score` via l'API, capable de se convertir en **vraies gloires** au Tribunal (via l'attribution automatique du top 3). Plafond d'abord ramené à 5000 puis, après un deuxième passage d'audit jugeant encore ça trop haut pour être un vrai plafond réaliste, resserré à 500 pour Icare / 2000 pour Corne (valeurs différentes : l'économie de score de Corne, basée sur des fusions enchaînées plutôt qu'une vitesse de défilement fixe, est plus dure à borner précisément — voir juste en dessous). Appliqué à deux niveaux dans les deux cas : la contrainte `check` sur `best_score` (la vraie garantie, `alter table ... add constraint` — un `check` inline sur `create table if not exists` ne s'appliquerait pas rétroactivement à une table déjà créée) et, en défense en profondeur, la validation dans la RPC elle-même. -Pour Icare spécifiquement, une deuxième couche orthogonale à la borne fixe : `public.icarus_runs` (`id, user_id, started_at`) enregistre le vrai instant de début de partie côté serveur (horloge Postgres, `started_at default now()`) au premier battement d'aile (`start_icarus_run()`, appelée depuis `IcarusGame`'s nouveau prop `onStart`) ; `submit_icarus_score(p_score, p_run_id)` calcule ensuite `now() - started_at` et rejette un score qui excéderait le rythme le plus rapide théoriquement atteignable dans le jeu (vitesse de défilement pleinement montée en difficulté **et** bouclier du boost actif en continu — un cas déjà irréaliste en soi — avec une marge de sécurité supplémentaire de 10 % contre le jitter de la boucle de jeu). Toujours le temps **réel** écoulé mesuré par le serveur, jamais une durée envoyée par le client (qui serait aussi falsifiable que le score lui-même). `icarus_runs` n'a aucun grant/policy pour `authenticated` : seules `start_icarus_run()`/`submit_icarus_score()` (SECURITY DEFINER) y touchent. Pas encore appliqué à Corne (`melon_scores`) dans cette passe — la formule de score par fusions enchaînées est plus dure à borner analytiquement sans données de vraies parties ; son plafond fixe (2000) reste donc, pour l'instant, une estimation plus prudente plutôt qu'un calcul aussi précis que pour Icare. +Pour Icare spécifiquement, une deuxième couche avait été ajoutée ici, orthogonale à la borne fixe : `public.icarus_runs` enregistrait le vrai instant de début de partie côté serveur et `submit_icarus_score` rejetait un score incohérent avec le temps réellement écoulé. **Cette approche a depuis été remplacée** par le mécanisme de signalement d'Alexandre (`game_cheat_flags`, voir V12 en §1) — `icarus_runs`/`start_icarus_run()` sont désormais supprimées (`drop table`/`drop function` en tête de la section 11 dans `schema.sql`), pas juste orphelines. Bucket Storage `avatars` : `file_size_limit`/`allowed_mime_types` ajoutés (`on conflict (id) do update`, pas `do nothing`, pour que ré-exécuter `schema.sql` applique bien la limite à un bucket déjà existant) — sans ça, un upload direct à l'API de Storage (hors de l'app, où c'est toujours `cropImageToBlob` qui compresse) pouvait déposer un fichier arbitrairement gros ou non-image comme avatar. Même limite que `wall-images` (3 Mo, image/webp+jpeg+png). @@ -123,8 +126,9 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul - `is_pseudo_taken(p_pseudo)` — anon + authenticated, ne renvoie qu'un booléen (l'anon ne peut pas lire `profiles`). - `award_points(p_target_id, p_delta, p_reason)` — authenticated, vérifie `role = 'judge'` côté serveur, jamais côté client. - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`. -- `start_icarus_run()` — authenticated, enregistre l'instant réel de début de partie (`icarus_runs.started_at`) pour la vérification par temps écoulé de `submit_icarus_score` — voir §3bis. -- `submit_icarus_score(p_score, p_run_id)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle) et un score incohérent avec le temps réellement écoulé depuis `p_run_id` (voir §3bis). +- ~~`start_icarus_run()`~~ — **supprimée** (voir §3bis/V12), remplacée par `game_cheat_flags`. +- `submit_icarus_score(p_score)` — authenticated, surcharge à 1 paramètre conservée pour compat (ancien client, appel RPC direct) : délègue à la version à 2 paramètres avec `p_run = null`, systématiquement signalée comme suspecte (`missing_run_telemetry`) mais jamais bloquée. +- `submit_icarus_score(p_score, p_run)` — authenticated, seul vrai point d'entrée du client actuel (`p_run` en `jsonb`, la télémétrie de partie). Vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur, mais après le signalement — voir ci-dessous), n'écrase le record que s'il est strictement battu, rejette un score hors bornes (0-500, défense en profondeur — la contrainte `check` sur `icarus_scores.best_score` est la garantie réelle). Signale dans `game_cheat_flags` (jamais de rejet) : télémétrie manquante/invalide, rythme de score trop rapide pour la vitesse max du jeu, score positif sans battement d'aile enregistré, ou cadence de battements extrême — voir V12 en §1. - `award_icarus_points_if_due()` — **`execute` explicitement révoqué de `public`/`anon`/`authenticated`** (voir §3bis), appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`). - `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, 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`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois. - `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé). diff --git a/supabase/schema.sql b/supabase/schema.sql index 19e0868..6efc3c9 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -579,53 +579,53 @@ create policy "icarus_scores readable by authenticated" -- é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; --- Deuxième couche, orthogonale à la borne fixe ci-dessus (audit de --- sécurité) : le jeu n'a aucune vérification de gameplay (génération --- procédurale entièrement côté client, voir V6/§3bis) — un score --- jusqu'à 500 reste soumettable tel quel sans avoir vraiment joué. Cette --- table fait tenir un compte du temps RÉEL écoulé (horloge du serveur, --- jamais une durée envoyée par le client — sinon aussi falsifiable que le --- score lui-même) entre le début d'une partie et sa soumission, pour --- rejeter un score incohérent avec le temps réellement passé. Aucun accès --- direct côté client (pas de grant) : seules les deux RPC ci-dessous --- (SECURITY DEFINER) la lisent/écrivent. -create table if not exists public.icarus_runs ( - id uuid primary key default gen_random_uuid(), - user_id uuid not null references public.profiles (id) on delete cascade, - started_at timestamptz not null default now() -); +-- Anti-triche par signalement (Alexandre, appliqué directement en base — +-- récupéré ici via pg_get_functiondef le 2026-08-25 pour que ce fichier +-- redevienne la vraie source de vérité, voir game_cheat_flags en section +-- 17). Remplace la couche par rejet serveur strict qui vivait ici +-- (icarus_runs/start_icarus_run(), voir §3bis/V12 dans CLAUDE.md) : jamais +-- de blocage, un score dont la télémétrie de partie (durée, nombre de +-- battements d'aile — envoyée par le client dans p_run, voir +-- IcarusRunTelemetry) est manquante, invalide, ou incohérente avec le +-- rythme maximal du jeu déclenche seulement un signalement dans +-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin. +-- Nettoyage de l'ancienne couche, devenue orpheline (plus aucun code +-- client ne l'appelle) : +drop function if exists public.submit_icarus_score(integer, uuid); +drop function if exists public.start_icarus_run(); +drop table if exists public.icarus_runs; -alter table public.icarus_runs enable row level security; +-- 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; -create or replace function public.start_icarus_run() -returns uuid +-- Surcharge à un seul paramètre : conservée pour compat (ancien client, +-- appel RPC direct sans télémétrie), délègue à la version avec p_run à +-- null — systématiquement signalée comme suspecte ci-dessous, mais jamais +-- bloquée. +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_id uuid; + v_row public.icarus_scores; begin - if auth.uid() is null then - raise exception 'authentication required'; - end if; + select * into v_row + from public.submit_icarus_score(p_score, null::jsonb); - insert into public.icarus_runs (user_id) values (auth.uid()) returning id into v_id; - return v_id; + return v_row; end; $$; -grant execute on function public.start_icarus_run() to authenticated; +grant execute on function public.submit_icarus_score(integer) to authenticated; --- Signature changée (ajout de p_run_id) : l'ancienne (integer) est --- explicitement supprimée, sinon create or replace créerait une 2e --- surcharge au lieu de remplacer (même remarque que post_wall_note). -drop function if exists public.submit_icarus_score(integer); - --- 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, p_run_id uuid) +-- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point. +create or replace function public.submit_icarus_score(p_score integer, p_run jsonb) returns public.icarus_scores language plpgsql security definer @@ -634,20 +634,10 @@ as $$ declare v_tribunal_date timestamptz; v_row public.icarus_scores; - v_started_at timestamptz; - v_elapsed_seconds double precision; - -- Temps minimal réel pour passer une colonne, au rythme le plus rapide - -- jamais atteignable dans le jeu : vitesse de défilement pleinement - -- montée en difficulté (FORWARD_SPEED × SPEED_MAX_MULTIPLIER) ET - -- bouclier du boost actif en permanence (× BOOST_SPEED_MULTIPLIER en - -- plus) — un cas déjà irréaliste en soi (le bouclier n'est ni continu - -- ni permanent), donc une marge de sécurité généreuse avant même le - -- ×0.9 ci-dessous. Doit rester aligné avec COLUMN_SPACING/FORWARD_SPEED/ - -- SPEED_MAX_MULTIPLIER/BOOST_SPEED_MULTIPLIER (src/lib/icarus/constants.ts). - -- 210 / (130 × 1.6 × 1.7) ≈ 0.594s, encore réduit de 10% (marge contre - -- le jitter d'arrondi de la boucle de jeu) : jamais assez strict pour - -- rejeter un score légitime, seulement pour rejeter l'impossible. - c_min_seconds_per_column constant double precision := 0.53; + v_previous_best integer; + v_duration_ms numeric; + v_actions numeric; + v_details jsonb; begin if auth.uid() is null then raise exception 'authentication required'; @@ -661,28 +651,115 @@ begin raise exception 'invalid score'; end if; - select tribunal_date into v_tribunal_date from public.settings where id = true; + select best_score into v_previous_best + from public.icarus_scores + where user_id = auth.uid(); - 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; + if p_run is null + or jsonb_typeof(p_run) is distinct from 'object' + or (p_run ->> 'v') is distinct from '1' then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'missing_run_telemetry', + 'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).', + jsonb_build_object('previous_best', v_previous_best) + ); + + else + if jsonb_typeof(p_run -> 'duration_ms') = 'number' then + v_duration_ms := (p_run ->> 'duration_ms')::numeric; + end if; + + if jsonb_typeof(p_run -> 'actions') = 'number' then + v_actions := (p_run ->> 'actions')::numeric; + end if; + + v_details := jsonb_build_object( + 'duration_ms', v_duration_ms, + 'actions', v_actions, + 'previous_best', v_previous_best + ); + + if v_duration_ms is null + or v_actions is null + or v_duration_ms < 0 + or v_actions < 0 then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'invalid_run_telemetry', + 'La télémétrie de la partie est absente ou invalide.', + v_details + ); + + else + if p_score >= 3 and v_duration_ms < p_score * 450 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'impossible_score_pace', + 'Le score a progressé plus vite que la vitesse maximale du jeu ne le permet.', + v_details + ); + end if; + + if p_score > 0 and v_actions < 1 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'score_without_input', + 'Un score positif a été soumis sans aucun battement d''aile enregistré.', + v_details + ); + end if; + + if v_duration_ms > 0 + and v_actions > (v_duration_ms / 1000) * 20 + 10 then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'warning', + 'extreme_input_rate', + 'Cadence de battements anormalement élevée sur la partie.', + v_details + ); + end if; + end if; end if; - if p_score > 0 then - select started_at into v_started_at - from public.icarus_runs - where id = p_run_id and user_id = auth.uid(); + select tribunal_date into v_tribunal_date + from public.settings + where id = true; - if v_started_at is null then - raise exception 'invalid run'; - end if; + 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(); - v_elapsed_seconds := extract(epoch from (now() - v_started_at)); - if p_score > floor(v_elapsed_seconds / c_min_seconds_per_column) then - raise exception 'score incohérent avec le temps de jeu écoulé'; - end if; - - delete from public.icarus_runs where id = p_run_id; + return v_row; end if; insert into public.icarus_scores (user_id, best_score, updated_at) @@ -692,12 +769,15 @@ begin 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(); + select * into v_row + from public.icarus_scores + where user_id = auth.uid(); + return v_row; end; $$; -grant execute on function public.submit_icarus_score(integer, uuid) to authenticated; +grant execute on function public.submit_icarus_score(integer, jsonb) to authenticated; do $$ begin @@ -1618,18 +1698,58 @@ create policy "melon_scores readable by authenticated" -- événement, comme icarus_points_awarded). alter table public.settings add column if not exists melon_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. +-- Anti-triche par signalement (Alexandre, appliqué directement en base — +-- récupéré ici via pg_get_functiondef le 2026-08-25, même mécanisme que +-- submit_icarus_score plus haut, voir §3bis/V12 dans CLAUDE.md et +-- game_cheat_flags en section 17) : jamais de blocage, une partie dont la +-- télémétrie (durée, nombre de lâchers, nombre de fusions — envoyée par le +-- client dans p_run, voir MelonRunTelemetry) est manquante, invalide, ou +-- incohérente avec ce que la physique du jeu permet (cooldown de lâcher, +-- nombre de fusions borné par le nombre de lâchers, score borné par le +-- gain maximal théorique par fusion) déclenche un signalement dans +-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin. +-- +-- Surcharge à un seul paramètre : conservée pour compat, délègue à la +-- version avec p_run à null — systématiquement signalée comme suspecte +-- ci-dessous, mais jamais bloquée. create or replace function public.submit_melon_score(p_score integer) returns public.melon_scores language plpgsql security definer set search_path = public as $$ +declare + v_row public.melon_scores; +begin + select * into v_row + from public.submit_melon_score(p_score, null::jsonb); + + return v_row; +end; +$$; + +grant execute on function public.submit_melon_score(integer) to authenticated; + +-- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point. +create or replace function public.submit_melon_score(p_score integer, p_run jsonb) +returns public.melon_scores +language plpgsql +security definer +set search_path = public +as $$ declare v_tribunal_date timestamptz; v_row public.melon_scores; + v_previous_best integer; + v_duration_ms numeric; + v_actions numeric; + v_merges numeric; + v_citizen_count integer; + v_max_tier integer; + v_details jsonb; begin if auth.uid() is null then raise exception 'authentication required'; @@ -1643,10 +1763,143 @@ begin raise exception 'invalid score'; end if; - select tribunal_date into v_tribunal_date from public.settings where id = true; + select best_score into v_previous_best + from public.melon_scores + where user_id = auth.uid(); + + select count(*)::integer into v_citizen_count + from public.profiles + where role = 'public'; + + v_max_tier := greatest(v_citizen_count - 1, 0); + + if p_run is null + or jsonb_typeof(p_run) is distinct from 'object' + or (p_run ->> 'v') is distinct from '1' then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'missing_run_telemetry', + 'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).', + jsonb_build_object('previous_best', v_previous_best) + ); + + else + if jsonb_typeof(p_run -> 'duration_ms') = 'number' then + v_duration_ms := (p_run ->> 'duration_ms')::numeric; + end if; + + if jsonb_typeof(p_run -> 'actions') = 'number' then + v_actions := (p_run ->> 'actions')::numeric; + end if; + + if jsonb_typeof(p_run -> 'merges') = 'number' then + v_merges := (p_run ->> 'merges')::numeric; + end if; + + v_details := jsonb_build_object( + 'duration_ms', v_duration_ms, + 'actions', v_actions, + 'merges', v_merges, + 'previous_best', v_previous_best + ); + + if v_duration_ms is null + or v_actions is null + or v_merges is null + or v_duration_ms < 0 + or v_actions < 0 + or v_merges < 0 then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'invalid_run_telemetry', + 'La télémétrie de la partie est absente ou invalide.', + v_details + ); + + else + if p_score > 0 and (v_actions < 2 or v_merges < 1) then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'score_without_required_moves', + 'Un score positif nécessite au moins deux lâchers et une fusion.', + v_details + ); + end if; + + if v_actions >= 2 + and v_duration_ms < (v_actions - 1) * 400 then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'impossible_drop_rate', + 'Les pièces ont été lâchées plus vite que le délai minimal du jeu ne le permet.', + v_details + ); + end if; + + if v_merges > greatest(v_actions - 1, 0) then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'impossible_merge_count', + 'Le nombre de fusions dépasse ce que le nombre de pièces lâchées permet.', + v_details + ); + end if; + + if v_merges > 0 + and p_score > v_merges * greatest(v_max_tier * 2, 2) then + + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'impossible_score_from_merges', + 'Le score dépasse le maximum théorique permis par les fusions enregistrées.', + v_details + ); + end if; + end if; + end if; + + select tribunal_date into v_tribunal_date + from public.settings + where id = true; if v_tribunal_date is not null and now() >= v_tribunal_date then - select * into v_row from public.melon_scores where user_id = auth.uid(); + select * into v_row + from public.melon_scores + where user_id = auth.uid(); + return v_row; end if; @@ -1657,12 +1910,15 @@ begin updated_at = now() where excluded.best_score > public.melon_scores.best_score; - select * into v_row from public.melon_scores where user_id = auth.uid(); + select * into v_row + from public.melon_scores + where user_id = auth.uid(); + return v_row; end; $$; -grant execute on function public.submit_melon_score(integer) to authenticated; +grant execute on function public.submit_melon_score(integer, jsonb) to authenticated; do $$ begin @@ -1738,3 +1994,43 @@ 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 de La Corne d''Abondance.'; end $$; + +-- 17. Anti-triche par signalement (Alexandre) -------------------------------- +-- Remplace icarus_runs (ancienne section 11, supprimée) côté client : plutôt +-- qu'un rejet serveur strict basé sur le temps réellement écoulé, cette +-- table journalise les parties suspectes (durée/nombre d'actions/fusions +-- incohérents avec le score, calculés côté client et envoyés dans p_run à +-- submit_icarus_score/submit_melon_score, section 11/16) pour qu'un +-- Archonte les vérifie à la main sur /admin (AdminCheatFlags) — les scores +-- concernés restent acceptés, rien n'est jamais bloqué automatiquement. +-- +-- Table, policy RLS et logique d'écriture (submit_icarus_score/ +-- submit_melon_score) toutes récupérées directement depuis Supabase +-- (dump de schéma + pg_policies + pg_get_functiondef, 2026-08-25) — ce +-- fichier est de nouveau la vraie source de vérité pour cette +-- fonctionnalité, contrairement à l'état précédent. +create table if not exists public.game_cheat_flags ( + id bigint generated always as identity primary key, + user_id uuid not null references public.profiles (id), + game text not null check (game in ('icarus', 'melon')), + score integer not null, + severity text not null check (severity in ('warning', 'high')), + trigger_code text not null, + reason text not null, + details jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +alter table public.game_cheat_flags enable row level security; + +-- Pas de grant insert/update/delete à authenticated : comme points_log/ +-- icarus_scores, seules les fonctions SECURITY DEFINER submit_icarus_score/ +-- submit_melon_score (section 11/16) peuvent y écrire. +revoke insert, update, delete on public.game_cheat_flags from authenticated, anon; +grant select on public.game_cheat_flags to authenticated; + +drop policy if exists "game_cheat_flags readable by judges" on public.game_cheat_flags; +create policy "game_cheat_flags readable by judges" + on public.game_cheat_flags for select + to authenticated + using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));