From 00743067ccd80ceb6f4d494fc0b1d89286529613 Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Tue, 25 Aug 2026 16:58:51 +0200 Subject: [PATCH] =?UTF-8?q?Ajoute=20un=20ping=20serveur=20r=C3=A9gulier=20?= =?UTF-8?q?comme=20troisi=C3=A8me=20couche=20d'anti-triche=20sur=20Icare?= =?UTF-8?q?=20et=20la=20Corne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit duration_ms/actions/merges restaient entièrement déclaratifs (calculés et envoyés par le client en une fois à la fin), donc falsifiables par un appel RPC direct. Le client envoie maintenant un ping toutes les 5s pendant la partie (ping_game_run), horodaté par le serveur, jamais par le client. submit_icarus_score/submit_melon_score corrèlent le nombre de pings et leur écart réel avec la durée annoncée (parties ≥ 15s uniquement) et signalent toute incohérence dans game_cheat_flags (missing_pings, ping_span_mismatch) — toujours en signalement silencieux, jamais de blocage, comme pour le reste de l'anti-triche. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 10 +- src/app/corne/corne-view.tsx | 44 ++++++++- src/app/corne/melon-game.tsx | 9 ++ src/app/icare/icare-view.tsx | 50 +++++++++- src/app/icare/icarus-game.tsx | 8 ++ supabase/schema.sql | 180 ++++++++++++++++++++++++++++++++++ 6 files changed, 290 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7666d1a..a68c737 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 à V12 sont livrées : +V1 à V13 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. @@ -21,6 +21,8 @@ V1 à V12 sont livrées : - **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. +- **V13** : troisième couche de l'anti-triche des mini-jeux (à la demande explicite de l'utilisateur, après qu'un premier signalement réel via V12 a confirmé que le mécanisme fonctionnait) — un ping régulier pendant la partie, en plus du signalement fondé sur la télémétrie déclarative de V12. `public.game_run_pings` (`id, user_id, game, session_token, created_at`) journalise chaque ping, horodaté par le **serveur** (jamais par le client, contrairement à `duration_ms`/`actions`/`merges` qui restent déclaratifs) ; `ping_game_run(p_game, p_session_token)` l'écrit. Côté client, `IcarusGame`/`MelonGame` retrouvent un prop `onStart` (le composant de jeu reste agnostique de Supabase, comme avant — c'est `IcareView`/`CorneView` qui gèrent le token/l'intervalle) déclenché au premier battement d'aile pour Icare, au montage et à chaque `resetGame()` pour Corne ; `IcareView`/`CorneView` y génèrent un `session_token` (`crypto.randomUUID()`), lancent un `setInterval` de 5s qui appelle `ping_game_run()` (best-effort, erreurs ignorées), l'arrêtent à la fin de la partie ou au démontage du composant, et l'incluent dans `p_run.session_token` envoyé à `submit_icarus_score`/`submit_melon_score`. Ces deux RPC corrèlent alors le nombre de pings et l'écart réel entre le premier et le dernier avec `duration_ms` annoncé (uniquement si celui-ci dépasse 15s, pour ne jamais signaler une partie courte/un échec immédiat) et ajoutent deux nouveaux `trigger_code` à `game_cheat_flags` : `missing_pings` (zéro ping reçu) et `ping_span_mismatch` (écart réel entre pings très inférieur à la durée annoncée) — toujours en signalement, jamais en blocage, même philosophie que V12. Décision produit assumée (l'utilisateur l'a explicitement refusée pour V12 aussi) : les signalements restent **volontairement invisibles** pour la personne concernée — le but est de savoir qui triche sans le prévenir, y compris si un score signalé finit par recevoir de vraies gloires automatiquement au Tribunal. + Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. --- @@ -77,8 +79,9 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo - `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`. +- `public.game_run_pings` (`id, user_id, game, session_token, created_at`) : depuis V13 (voir §1) — un ping toutes les 5s pendant une partie, horodaté par le serveur, pour corroborer `duration_ms` (déclaratif) avec une preuve indépendante. Aucun grant du tout pour `authenticated`/`anon` (même principe que l'ancien `icarus_runs`) : seules `ping_game_run()` (insert) et `submit_icarus_score`/`submit_melon_score` (lecture + nettoyage) y touchent. - `settings.melon_points_awarded` : marqueur d'idempotence, même principe que `icarus_points_awarded`. -- `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é). +- `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`, y compris les vérifications par ping de V13 — voir V12/V13 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) @@ -128,7 +131,8 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul - `reset_rank_reference()` — authenticated, vérifie `role = 'judge'`, fige le classement courant dans `previous_rank`. - ~~`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. +- `ping_game_run(p_game, p_session_token)` — authenticated, appelée toutes les 5s pendant une partie (Icare ou Corne) : horodate un ping dans `game_run_pings` pour la vérification par ping de `submit_icarus_score`/`submit_melon_score` — voir V13 en §1. +- `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, avec `session_token` depuis V13). 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é, cadence de battements extrême, ou (V13, uniquement si `duration_ms` ≥ 15s) aucun ping serveur reçu / écart réel entre pings très inférieur à la durée annoncée — voir V12/V13 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/src/app/corne/corne-view.tsx b/src/app/corne/corne-view.tsx index 5948a57..bebf4f2 100644 --- a/src/app/corne/corne-view.tsx +++ b/src/app/corne/corne-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Avatar } from "@/components/avatar"; import { Podium } from "@/components/podium"; import { IconClose, IconHorn } from "@/components/icons"; @@ -9,6 +9,10 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { MelonGame, type MelonRunTelemetry } from "./melon-game"; import type { MelonScoreEntry, MemberRow } from "./page"; +// Anti-triche par ping — voir la même remarque dans icare-view.tsx et +// supabase/schema.sql section 18. +const PING_INTERVAL_MS = 5000; + export function CorneView({ citizens, initialLeaderboard, @@ -23,6 +27,19 @@ export function CorneView({ const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [showRanking, setShowRanking] = useState(false); + const sessionTokenRef = useRef(null); + const pingIntervalRef = useRef | null>(null); + + const stopPinging = useCallback(() => { + if (pingIntervalRef.current !== null) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + }, []); + + // Coupe le ping si le joueur quitte /corne en pleine partie (sinon + // l'intervalle continuerait d'appeler la RPC pour un composant démonté). + useEffect(() => stopPinging, [stopPinging]); const refetch = useCallback(async () => { const supabase = createClient(); @@ -64,9 +81,28 @@ export function CorneView({ }; }, [refetch]); - async function handleFinish(score: number, run: MelonRunTelemetry) { + function handleStart() { const supabase = createClient(); - const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: run }); + const token = crypto.randomUUID(); + sessionTokenRef.current = token; + + stopPinging(); + pingIntervalRef.current = setInterval(() => { + // Best-effort : un ping manqué (réseau, onglet en arrière-plan) est + // sans conséquence, submit_melon_score reste tolérant (marge de 15s + // avant le premier ping attendu, voir schema.sql section 18). + supabase.rpc("ping_game_run", { p_game: "melon", p_session_token: token }).then( + () => {}, + () => {}, + ); + }, PING_INTERVAL_MS); + } + + async function handleFinish(score: number, run: MelonRunTelemetry) { + stopPinging(); + const supabase = createClient(); + const runWithToken = { ...run, session_token: sessionTokenRef.current }; + const { data, error } = await supabase.rpc("submit_melon_score", { p_score: score, p_run: runWithToken }); if (!error && data) { setOwnBestScore(data.best_score); refetch(); @@ -100,7 +136,7 @@ export function CorneView({ Classement - + {ownBestScore !== null && (

diff --git a/src/app/corne/melon-game.tsx b/src/app/corne/melon-game.tsx index 4ddf6b0..c651384 100644 --- a/src/app/corne/melon-game.tsx +++ b/src/app/corne/melon-game.tsx @@ -39,9 +39,11 @@ export type MelonRunTelemetry = { export function MelonGame({ citizens, + onStart, onFinish, }: { citizens: MemberRow[]; + onStart: () => void; onFinish: (score: number, run: MelonRunTelemetry) => void; }) { const containerRef = useRef(null); @@ -67,6 +69,11 @@ export function MelonGame({ // viser), pour voir venir deux coups à l'avance au lieu d'un seul. const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier)); + const onStartRef = useRef(onStart); + useEffect(() => { + onStartRef.current = onStart; + }, [onStart]); + const onFinishRef = useRef(onFinish); useEffect(() => { onFinishRef.current = onFinish; @@ -91,6 +98,7 @@ export function MelonGame({ const runStartedAtRef = useRef(0); useEffect(() => { runStartedAtRef.current = Date.now(); + onStartRef.current(); }, []); const dropCountRef = useRef(0); const mergeCountRef = useRef(0); @@ -152,6 +160,7 @@ export function MelonGame({ mergingBodyIdsRef.current.clear(); dangerSinceRef.current = null; runStartedAtRef.current = Date.now(); + onStartRef.current(); dropCountRef.current = 0; mergeCountRef.current = 0; lastDropAtRef.current = -Infinity; diff --git a/src/app/icare/icare-view.tsx b/src/app/icare/icare-view.tsx index a26de2f..491c7ac 100644 --- a/src/app/icare/icare-view.tsx +++ b/src/app/icare/icare-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Avatar } from "@/components/avatar"; import { Podium } from "@/components/podium"; import { IconClose, IconTrophy } from "@/components/icons"; @@ -9,6 +9,15 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { IcarusGame, type IcarusRunTelemetry } from "./icarus-game"; import type { IcarusScoreEntry } from "./page"; +// Anti-triche par ping (complète le signalement d'Alexandre, voir +// supabase/schema.sql section 18) : la durée/nombre d'actions envoyées à la +// fin d'une partie sont déclaratives, un ping régulier horodaté par le +// serveur PENDANT la partie donne une preuve indépendante. Rythme choisi +// pour rester léger (pas de souci de charge à cette fréquence pour ~12 +// joueurs) tout en laissant peu de marge à une partie fabriquée sans +// jamais avoir été réellement ouverte. +const PING_INTERVAL_MS = 5000; + export function IcareView({ initialLeaderboard, initialOwnBestScore, @@ -21,6 +30,20 @@ export function IcareView({ const [leaderboard, setLeaderboard] = useState(initialLeaderboard); const [ownBestScore, setOwnBestScore] = useState(initialOwnBestScore); const [showRanking, setShowRanking] = useState(false); + const sessionTokenRef = useRef(null); + const pingIntervalRef = useRef | null>(null); + + const stopPinging = useCallback(() => { + if (pingIntervalRef.current !== null) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + }, []); + + // Coupe le ping si le joueur quitte /icare en pleine partie (sinon + // l'intervalle continuerait d'appeler la RPC pour un composant démonté). + useEffect(() => stopPinging, [stopPinging]); + const refetch = useCallback(async () => { const supabase = createClient(); const { data: scores } = await supabase @@ -66,9 +89,28 @@ export function IcareView({ }; }, [refetch]); - async function handleFinish(score: number, run: IcarusRunTelemetry) { + function handleStart() { const supabase = createClient(); - const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: run }); + const token = crypto.randomUUID(); + sessionTokenRef.current = token; + + stopPinging(); + pingIntervalRef.current = setInterval(() => { + // Best-effort : un ping manqué (réseau, onglet en arrière-plan) est + // sans conséquence, submit_icarus_score reste tolérant (marge de 15s + // avant le premier ping attendu, voir schema.sql section 18). + supabase.rpc("ping_game_run", { p_game: "icarus", p_session_token: token }).then( + () => {}, + () => {}, + ); + }, PING_INTERVAL_MS); + } + + async function handleFinish(score: number, run: IcarusRunTelemetry) { + stopPinging(); + const supabase = createClient(); + const runWithToken = { ...run, session_token: sessionTokenRef.current }; + const { data, error } = await supabase.rpc("submit_icarus_score", { p_score: score, p_run: runWithToken }); if (!error && data) { setOwnBestScore(data.best_score); refetch(); @@ -97,7 +139,7 @@ export function IcareView({ - + {showRanking && (

diff --git a/src/app/icare/icarus-game.tsx b/src/app/icare/icarus-game.tsx index f388e43..c9ec850 100644 --- a/src/app/icare/icarus-game.tsx +++ b/src/app/icare/icarus-game.tsx @@ -278,8 +278,10 @@ export type IcarusRunTelemetry = { }; export function IcarusGame({ + onStart, onFinish, }: { + onStart: () => void; onFinish: (score: number, run: IcarusRunTelemetry) => void; }) { const canvasRef = useRef(null); @@ -287,6 +289,11 @@ export function IcarusGame({ const [status, setStatus] = useState("idle"); const [score, setScore] = useState(0); + const onStartRef = useRef(onStart); + useEffect(() => { + onStartRef.current = onStart; + }, [onStart]); + const onFinishRef = useRef(onFinish); useEffect(() => { onFinishRef.current = onFinish; @@ -338,6 +345,7 @@ export function IcarusGame({ actionCountRef.current = 1; statusRef.current = "flying"; setStatus("flying"); + onStartRef.current(); } else { actionCountRef.current += 1; } diff --git a/supabase/schema.sql b/supabase/schema.sql index 6efc3c9..c66495f 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -638,6 +638,15 @@ declare v_duration_ms numeric; v_actions numeric; v_details jsonb; + -- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(), + -- section 18) : preuve horodatée par le SERVEUR, contrairement à + -- duration_ms/actions ci-dessus qui restent entièrement déclaratifs + -- (calculés et envoyés par le client en une seule fois à la fin, donc + -- falsifiables par un appel RPC direct sans avoir vraiment joué). + v_session_token uuid; + v_ping_count integer; + v_first_ping_at timestamptz; + v_last_ping_at timestamptz; begin if auth.uid() is null then raise exception 'authentication required'; @@ -680,6 +689,17 @@ begin v_actions := (p_run ->> 'actions')::numeric; end if; + -- Extraction protégée (begin/exception imbriqué) : une valeur + -- malformée ne doit jamais faire échouer toute la soumission, juste + -- être traitée comme une absence de token (donc 0 ping trouvé, + -- signalé plus bas comme les autres incohérences). + begin + v_session_token := nullif(p_run ->> 'session_token', '')::uuid; + exception + when invalid_text_representation then + v_session_token := null; + end; + v_details := jsonb_build_object( 'duration_ms', v_duration_ms, 'actions', v_actions, @@ -747,6 +767,53 @@ begin v_details ); end if; + + -- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive + -- qu'après un cycle d'intervalle côté client, ~5s) : pas de + -- signalement pour une partie courte, y compris un échec immédiat. + if v_duration_ms >= 15000 then + select count(*), min(created_at), max(created_at) + into v_ping_count, v_first_ping_at, v_last_ping_at + from public.game_run_pings + where user_id = auth.uid() and game = 'icarus' and session_token = v_session_token; + + if v_ping_count = 0 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'missing_pings', + 'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.', + v_details || jsonb_build_object('ping_count', v_ping_count) + ); + elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'icarus', + p_score, + 'high', + 'ping_span_mismatch', + 'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.', + v_details || jsonb_build_object( + 'ping_count', v_ping_count, + 'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 + ) + ); + end if; + end if; + end if; + + -- Nettoyage : les pings de cette partie ont été consommés (signalés ou + -- non), inutile de les garder — évite aussi qu'un même session_token + -- réutilisé par erreur ne corrèle avec une partie précédente. + if v_session_token is not null then + delete from public.game_run_pings + where user_id = auth.uid() and session_token = v_session_token; end if; end if; @@ -1750,6 +1817,14 @@ declare v_citizen_count integer; v_max_tier integer; v_details jsonb; + -- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(), + -- section 18) : preuve horodatée par le SERVEUR, contrairement à + -- duration_ms/actions/merges ci-dessus qui restent entièrement + -- déclaratifs — voir la même remarque sur submit_icarus_score. + v_session_token uuid; + v_ping_count integer; + v_first_ping_at timestamptz; + v_last_ping_at timestamptz; begin if auth.uid() is null then raise exception 'authentication required'; @@ -1802,6 +1877,17 @@ begin v_merges := (p_run ->> 'merges')::numeric; end if; + -- Extraction protégée (begin/exception imbriqué) : une valeur + -- malformée ne doit jamais faire échouer toute la soumission, juste + -- être traitée comme une absence de token (donc 0 ping trouvé, + -- signalé plus bas comme les autres incohérences). + begin + v_session_token := nullif(p_run ->> 'session_token', '')::uuid; + exception + when invalid_text_representation then + v_session_token := null; + end; + v_details := jsonb_build_object( 'duration_ms', v_duration_ms, 'actions', v_actions, @@ -1888,6 +1974,53 @@ begin v_details ); end if; + + -- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive + -- qu'après un cycle d'intervalle côté client, ~5s) : pas de + -- signalement pour une partie courte, y compris un échec immédiat. + if v_duration_ms >= 15000 then + select count(*), min(created_at), max(created_at) + into v_ping_count, v_first_ping_at, v_last_ping_at + from public.game_run_pings + where user_id = auth.uid() and game = 'melon' and session_token = v_session_token; + + if v_ping_count = 0 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'missing_pings', + 'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.', + v_details || jsonb_build_object('ping_count', v_ping_count) + ); + elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then + insert into public.game_cheat_flags + (user_id, game, score, severity, trigger_code, reason, details) + values ( + auth.uid(), + 'melon', + p_score, + 'high', + 'ping_span_mismatch', + 'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.', + v_details || jsonb_build_object( + 'ping_count', v_ping_count, + 'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 + ) + ); + end if; + end if; + end if; + + -- Nettoyage : les pings de cette partie ont été consommés (signalés ou + -- non), inutile de les garder — évite aussi qu'un même session_token + -- réutilisé par erreur ne corrèle avec une partie précédente. + if v_session_token is not null then + delete from public.game_run_pings + where user_id = auth.uid() and session_token = v_session_token; end if; end if; @@ -2034,3 +2167,50 @@ 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')); + +-- 18. Anti-triche : ping régulier pendant la partie -------------------------- +-- Complète le signalement (section 17) : duration_ms/actions/merges dans +-- p_run sont entièrement déclaratifs (calculés et envoyés par le client en +-- une seule fois à la fin), donc falsifiables par quelqu'un qui appelle +-- submit_icarus_score/submit_melon_score directement sans avoir vraiment +-- joué. Le client envoie un ping régulier PENDANT la partie (toutes les +-- ~5s tant qu'elle est en cours), horodaté ici par l'horloge du SERVEUR — +-- submit_icarus_score/submit_melon_score (section 11/16) corrèlent ensuite +-- le nombre de pings et l'écart réel entre le premier et le dernier avec +-- la durée annoncée, et signalent (jamais ne bloquent, même philosophie +-- que le reste de l'anti-triche) toute incohérence. +create table if not exists public.game_run_pings ( + id bigint generated always as identity primary key, + user_id uuid not null references public.profiles (id) on delete cascade, + game text not null check (game in ('icarus', 'melon')), + session_token uuid not null, + created_at timestamptz not null default now() +); + +alter table public.game_run_pings enable row level security; +-- Aucun grant à authenticated/anon (même principe que l'ancien icarus_runs, +-- section 11) : seules ping_game_run() (insert) et submit_icarus_score/ +-- submit_melon_score (lecture + nettoyage), toutes SECURITY DEFINER, +-- contournent la RLS en tant que propriétaires des fonctions. + +create or replace function public.ping_game_run(p_game text, p_session_token uuid) +returns void +language plpgsql +security definer +set search_path = public +as $$ +begin + if auth.uid() is null then + raise exception 'authentication required'; + end if; + + if p_game not in ('icarus', 'melon') then + raise exception 'invalid game'; + end if; + + insert into public.game_run_pings (user_id, game, session_token) + values (auth.uid(), p_game, p_session_token); +end; +$$; + +grant execute on function public.ping_game_run(text, uuid) to authenticated;