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
+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">