Ajoute un ping serveur régulier comme troisième couche d'anti-triche sur Icare et la Corne
Build and deploy / deploy (push) Successful in 36s

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 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-25 16:58:51 +02:00
parent 088cd92e5f
commit 00743067cc
6 changed files with 290 additions and 11 deletions
+7 -3
View File
@@ -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é).
+40 -4
View File
@@ -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<string | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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
</button>
<MelonGame citizens={citizens} onFinish={handleFinish} />
<MelonGame citizens={citizens} onStart={handleStart} onFinish={handleFinish} />
{ownBestScore !== null && (
<p className="text-center font-heading text-sm tracking-wide text-marble">
+9
View File
@@ -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<HTMLDivElement | null>(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;
+46 -4
View File
@@ -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<string | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | 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({
</button>
</div>
<IcarusGame onFinish={handleFinish} />
<IcarusGame onStart={handleStart} onFinish={handleFinish} />
{showRanking && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm">
+8
View File
@@ -278,8 +278,10 @@ export type IcarusRunTelemetry = {
};
export function IcarusGame({
onStart,
onFinish,
}: {
onStart: () => void;
onFinish: (score: number, run: IcarusRunTelemetry) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -287,6 +289,11 @@ export function IcarusGame({
const [status, setStatus] = useState<Status>("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;
}
+180
View File
@@ -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;