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
+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;
}